@actuate-media/cms-admin 0.3.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +23 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +1 -0
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts +7 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +35 -11
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +49 -5
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/ForgotPassword.d.ts +5 -0
- package/dist/views/ForgotPassword.d.ts.map +1 -0
- package/dist/views/ForgotPassword.js +41 -0
- package/dist/views/ForgotPassword.js.map +1 -0
- package/dist/views/ResetPassword.d.ts +6 -0
- package/dist/views/ResetPassword.d.ts.map +1 -0
- package/dist/views/ResetPassword.js +46 -0
- package/dist/views/ResetPassword.js.map +1 -0
- package/dist/views/ScriptTagEditor.d.ts +6 -0
- package/dist/views/ScriptTagEditor.d.ts.map +1 -0
- package/dist/views/ScriptTagEditor.js +109 -0
- package/dist/views/ScriptTagEditor.js.map +1 -0
- package/dist/views/ScriptTags.d.ts +5 -0
- package/dist/views/ScriptTags.d.ts.map +1 -0
- package/dist/views/ScriptTags.js +54 -0
- package/dist/views/ScriptTags.js.map +1 -0
- package/dist/views/Settings.d.ts +2 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +31 -3
- package/dist/views/Settings.js.map +1 -1
- package/package.json +5 -3
- package/src/AdminRoot.tsx +26 -1
- package/src/components/Breadcrumbs.tsx +1 -0
- package/src/index.ts +4 -0
- package/src/layout/Sidebar.tsx +72 -22
- package/src/views/DocumentEdit.tsx +82 -4
- package/src/views/ForgotPassword.tsx +136 -0
- package/src/views/ResetPassword.tsx +192 -0
- package/src/views/ScriptTagEditor.tsx +361 -0
- package/src/views/ScriptTags.tsx +174 -0
- package/src/views/Settings.tsx +79 -2
|
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
4
4
|
import { AlertTriangle, Eye, EyeOff, Save, Copy, Loader2, Clock } from 'lucide-react';
|
|
5
5
|
import { toast } from 'sonner';
|
|
6
6
|
import { FieldRenderer } from '../fields/FieldRenderer.js';
|
|
7
|
+
import { RelationshipField } from '../fields/RelationshipField.js';
|
|
7
8
|
import { Button } from '../components/ui/Button.js';
|
|
8
9
|
import { Badge } from '../components/ui/Badge.js';
|
|
9
10
|
import { LivePreview } from '../components/LivePreview.js';
|
|
@@ -26,6 +27,8 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
26
27
|
const [initialValues, setInitialValues] = useState<Record<string, any>>({});
|
|
27
28
|
const [seoData, setSeoData] = useState<SEOData>({});
|
|
28
29
|
const [initialSeoData, setInitialSeoData] = useState<SEOData>({});
|
|
30
|
+
const [layoutAssignments, setLayoutAssignments] = useState<Record<string, string>>({});
|
|
31
|
+
const [initialLayoutAssignments, setInitialLayoutAssignments] = useState<Record<string, string>>({});
|
|
29
32
|
const [saving, setSaving] = useState(false);
|
|
30
33
|
const [loading, setLoading] = useState(!isNew);
|
|
31
34
|
const [showPreview, setShowPreview] = useState(false);
|
|
@@ -38,6 +41,16 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
38
41
|
? collections.find((c: any) => c.slug === collectionSlug)
|
|
39
42
|
: collections?.[collectionSlug];
|
|
40
43
|
|
|
44
|
+
const layoutConfig = config?.layout;
|
|
45
|
+
const layoutRegions: Array<{ name: string; collection: string; label: string }> = layoutConfig?.regions
|
|
46
|
+
? Object.entries(layoutConfig.regions).map(([name, region]: [string, any]) => ({
|
|
47
|
+
name,
|
|
48
|
+
collection: region.collection,
|
|
49
|
+
label: region.label ?? name.charAt(0).toUpperCase() + name.slice(1),
|
|
50
|
+
}))
|
|
51
|
+
: [];
|
|
52
|
+
const hasLayout = layoutRegions.length > 0 && (collection?.type === 'page' || collection?.urlPrefix !== undefined);
|
|
53
|
+
|
|
41
54
|
const previewUrl = collection?.admin?.preview ? collection.admin.preview({}) : undefined;
|
|
42
55
|
const fields: any[] = collection?.fields
|
|
43
56
|
? (Array.isArray(collection.fields)
|
|
@@ -51,7 +64,8 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
51
64
|
: `Edit ${values[useAsTitleField] ?? 'Document'}`;
|
|
52
65
|
|
|
53
66
|
const isDirty = JSON.stringify(values) !== JSON.stringify(initialValues)
|
|
54
|
-
|| JSON.stringify(seoData) !== JSON.stringify(initialSeoData)
|
|
67
|
+
|| JSON.stringify(seoData) !== JSON.stringify(initialSeoData)
|
|
68
|
+
|| JSON.stringify(layoutAssignments) !== JSON.stringify(initialLayoutAssignments);
|
|
55
69
|
|
|
56
70
|
useEffect(() => {
|
|
57
71
|
if (!isNew && documentId && !hasLoadedRef.current) {
|
|
@@ -95,6 +109,12 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
95
109
|
}
|
|
96
110
|
setSeoData(loadedSeo);
|
|
97
111
|
setInitialSeoData(loadedSeo);
|
|
112
|
+
|
|
113
|
+
if (docData._layout && typeof docData._layout === 'object') {
|
|
114
|
+
const loaded = docData._layout as Record<string, string>;
|
|
115
|
+
setLayoutAssignments(loaded);
|
|
116
|
+
setInitialLayoutAssignments(loaded);
|
|
117
|
+
}
|
|
98
118
|
} else if (res.error) {
|
|
99
119
|
toast.error(`Failed to load document: ${res.error}`);
|
|
100
120
|
}
|
|
@@ -107,7 +127,8 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
107
127
|
|
|
108
128
|
async function handleSave() {
|
|
109
129
|
setSaving(true);
|
|
110
|
-
const
|
|
130
|
+
const layoutPayload = Object.keys(layoutAssignments).length > 0 ? { _layout: layoutAssignments } : {};
|
|
131
|
+
const payload = { ...values, ...seoData, ...layoutPayload };
|
|
111
132
|
try {
|
|
112
133
|
if (isNew) {
|
|
113
134
|
const res = await cmsApi<any>(`/collections/${collectionSlug}`, {
|
|
@@ -121,6 +142,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
121
142
|
const newId = res.data?.id;
|
|
122
143
|
setInitialValues(values);
|
|
123
144
|
setInitialSeoData(seoData);
|
|
145
|
+
setInitialLayoutAssignments(layoutAssignments);
|
|
124
146
|
if (newId && onNavigate) {
|
|
125
147
|
onNavigate(`/${collectionSlug}/${newId}`);
|
|
126
148
|
}
|
|
@@ -136,6 +158,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
136
158
|
toast.success('Changes saved');
|
|
137
159
|
setInitialValues(values);
|
|
138
160
|
setInitialSeoData(seoData);
|
|
161
|
+
setInitialLayoutAssignments(layoutAssignments);
|
|
139
162
|
if (res.data?.status) setDocStatus(res.data.status);
|
|
140
163
|
}
|
|
141
164
|
}
|
|
@@ -148,9 +171,10 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
148
171
|
async function handlePublish() {
|
|
149
172
|
if (isNew) return;
|
|
150
173
|
setSaving(true);
|
|
174
|
+
const layoutPayload = Object.keys(layoutAssignments).length > 0 ? { _layout: layoutAssignments } : {};
|
|
151
175
|
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`, {
|
|
152
176
|
method: 'PUT',
|
|
153
|
-
body: JSON.stringify({ ...values, ...seoData, status: 'PUBLISHED' }),
|
|
177
|
+
body: JSON.stringify({ ...values, ...seoData, ...layoutPayload, status: 'PUBLISHED' }),
|
|
154
178
|
});
|
|
155
179
|
if (res.error) {
|
|
156
180
|
toast.error(res.error);
|
|
@@ -159,6 +183,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
159
183
|
setDocStatus('PUBLISHED');
|
|
160
184
|
setInitialValues(values);
|
|
161
185
|
setInitialSeoData(seoData);
|
|
186
|
+
setInitialLayoutAssignments(layoutAssignments);
|
|
162
187
|
}
|
|
163
188
|
setSaving(false);
|
|
164
189
|
}
|
|
@@ -166,9 +191,10 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
166
191
|
async function handleUnpublish() {
|
|
167
192
|
if (isNew) return;
|
|
168
193
|
setSaving(true);
|
|
194
|
+
const layoutPayload = Object.keys(layoutAssignments).length > 0 ? { _layout: layoutAssignments } : {};
|
|
169
195
|
const res = await cmsApi<any>(`/collections/${collectionSlug}/${documentId}`, {
|
|
170
196
|
method: 'PUT',
|
|
171
|
-
body: JSON.stringify({ ...values, ...seoData, status: 'DRAFT' }),
|
|
197
|
+
body: JSON.stringify({ ...values, ...seoData, ...layoutPayload, status: 'DRAFT' }),
|
|
172
198
|
});
|
|
173
199
|
if (res.error) {
|
|
174
200
|
toast.error(res.error);
|
|
@@ -177,6 +203,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
177
203
|
setDocStatus('DRAFT');
|
|
178
204
|
setInitialValues(values);
|
|
179
205
|
setInitialSeoData(seoData);
|
|
206
|
+
setInitialLayoutAssignments(layoutAssignments);
|
|
180
207
|
}
|
|
181
208
|
setSaving(false);
|
|
182
209
|
}
|
|
@@ -335,6 +362,57 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
335
362
|
onChange={setSeoData}
|
|
336
363
|
siteUrl={config?.siteUrl}
|
|
337
364
|
/>
|
|
365
|
+
|
|
366
|
+
{hasLayout && (
|
|
367
|
+
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
|
368
|
+
<h3 className="mb-1 font-semibold">Layout</h3>
|
|
369
|
+
<p className="mb-4 text-xs text-[var(--muted-foreground)]">
|
|
370
|
+
Assign header/footer variants. Child pages inherit from ancestors.
|
|
371
|
+
</p>
|
|
372
|
+
<div className="space-y-4">
|
|
373
|
+
{layoutRegions.map((region) => (
|
|
374
|
+
<div key={region.name}>
|
|
375
|
+
<RelationshipField
|
|
376
|
+
label={region.label}
|
|
377
|
+
value={layoutAssignments[region.name] ?? ''}
|
|
378
|
+
onChange={(val) => {
|
|
379
|
+
setLayoutAssignments((prev) => {
|
|
380
|
+
const next = { ...prev };
|
|
381
|
+
if (val && typeof val === 'string') {
|
|
382
|
+
next[region.name] = val;
|
|
383
|
+
} else {
|
|
384
|
+
delete next[region.name];
|
|
385
|
+
}
|
|
386
|
+
return next;
|
|
387
|
+
});
|
|
388
|
+
}}
|
|
389
|
+
relationTo={region.collection}
|
|
390
|
+
helpText={
|
|
391
|
+
!layoutAssignments[region.name]
|
|
392
|
+
? 'Inheriting from parent page or site default'
|
|
393
|
+
: undefined
|
|
394
|
+
}
|
|
395
|
+
/>
|
|
396
|
+
{layoutAssignments[region.name] && (
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={() => {
|
|
400
|
+
setLayoutAssignments((prev) => {
|
|
401
|
+
const next = { ...prev };
|
|
402
|
+
delete next[region.name];
|
|
403
|
+
return next;
|
|
404
|
+
});
|
|
405
|
+
}}
|
|
406
|
+
className="mt-1 text-xs text-[var(--primary)] hover:underline"
|
|
407
|
+
>
|
|
408
|
+
Clear override (inherit from parent)
|
|
409
|
+
</button>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
))}
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
338
416
|
</div>
|
|
339
417
|
</div>
|
|
340
418
|
)}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, type FormEvent } from 'react';
|
|
4
|
+
import { Shield, ArrowLeft, Loader2, CheckCircle2, AlertTriangle } from 'lucide-react';
|
|
5
|
+
import { cmsApi } from '../lib/api.js';
|
|
6
|
+
|
|
7
|
+
export interface ForgotPasswordProps {
|
|
8
|
+
onNavigate: (path: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ForgotPassword({ onNavigate }: ForgotPasswordProps) {
|
|
12
|
+
const [email, setEmail] = useState('');
|
|
13
|
+
const [submitting, setSubmitting] = useState(false);
|
|
14
|
+
const [sent, setSent] = useState(false);
|
|
15
|
+
const [error, setError] = useState('');
|
|
16
|
+
|
|
17
|
+
const canSubmit = email.trim() && !submitting;
|
|
18
|
+
|
|
19
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
if (!canSubmit) return;
|
|
22
|
+
|
|
23
|
+
setError('');
|
|
24
|
+
setSubmitting(true);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const result = await cmsApi('/auth/forgot-password', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
body: JSON.stringify({ email: email.trim() }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (result.error && result.status === 429) {
|
|
33
|
+
setError('Too many requests. Please try again later.');
|
|
34
|
+
} else {
|
|
35
|
+
setSent(true);
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
setError('An unexpected error occurred. Please try again.');
|
|
39
|
+
} finally {
|
|
40
|
+
setSubmitting(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
|
46
|
+
<div className="w-full max-w-md">
|
|
47
|
+
<div className="text-center mb-8">
|
|
48
|
+
<div className="mx-auto mb-4 w-14 h-14 bg-blue-600 rounded-xl flex items-center justify-center">
|
|
49
|
+
<Shield className="w-7 h-7 text-white" />
|
|
50
|
+
</div>
|
|
51
|
+
<h1 className="text-2xl font-bold text-gray-900">Reset Password</h1>
|
|
52
|
+
<p className="text-gray-600 mt-2">
|
|
53
|
+
{sent
|
|
54
|
+
? 'Check your inbox for a reset link'
|
|
55
|
+
: "Enter your email and we'll send you a reset link"}
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm space-y-5">
|
|
60
|
+
{sent ? (
|
|
61
|
+
<div className="text-center space-y-4">
|
|
62
|
+
<div className="mx-auto w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
|
63
|
+
<CheckCircle2 className="w-6 h-6 text-green-600" />
|
|
64
|
+
</div>
|
|
65
|
+
<p className="text-sm text-gray-600">
|
|
66
|
+
If an account exists for <strong>{email}</strong>, you will receive a password reset email shortly.
|
|
67
|
+
</p>
|
|
68
|
+
<p className="text-xs text-gray-500">
|
|
69
|
+
The link expires in 1 hour. Check your spam folder if you don't see it.
|
|
70
|
+
</p>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={() => onNavigate('/login')}
|
|
74
|
+
className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
|
75
|
+
>
|
|
76
|
+
Back to Sign In
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
) : (
|
|
80
|
+
<form onSubmit={handleSubmit} className="space-y-5">
|
|
81
|
+
{error && (
|
|
82
|
+
<div className="flex items-start gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
83
|
+
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 shrink-0" />
|
|
84
|
+
<p className="text-sm text-red-800">{error}</p>
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
<div>
|
|
89
|
+
<label htmlFor="forgot-email" className="block text-sm font-medium text-gray-700 mb-1.5">
|
|
90
|
+
Email Address
|
|
91
|
+
</label>
|
|
92
|
+
<input
|
|
93
|
+
id="forgot-email"
|
|
94
|
+
type="email"
|
|
95
|
+
value={email}
|
|
96
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
97
|
+
placeholder="admin@example.com"
|
|
98
|
+
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
99
|
+
required
|
|
100
|
+
autoFocus
|
|
101
|
+
autoComplete="email"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<button
|
|
106
|
+
type="submit"
|
|
107
|
+
disabled={!canSubmit}
|
|
108
|
+
className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
109
|
+
>
|
|
110
|
+
{submitting ? (
|
|
111
|
+
<>
|
|
112
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
113
|
+
Sending...
|
|
114
|
+
</>
|
|
115
|
+
) : (
|
|
116
|
+
'Send Reset Link'
|
|
117
|
+
)}
|
|
118
|
+
</button>
|
|
119
|
+
</form>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{!sent && (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => onNavigate('/login')}
|
|
126
|
+
className="w-full flex items-center justify-center gap-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
|
127
|
+
>
|
|
128
|
+
<ArrowLeft className="w-4 h-4" />
|
|
129
|
+
Back to Sign In
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, type FormEvent } from 'react';
|
|
4
|
+
import { Shield, Eye, EyeOff, Loader2, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react';
|
|
5
|
+
import { cmsApi } from '../lib/api.js';
|
|
6
|
+
|
|
7
|
+
export interface ResetPasswordProps {
|
|
8
|
+
onNavigate: (path: string) => void;
|
|
9
|
+
token: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ResetPassword({ onNavigate, token }: ResetPasswordProps) {
|
|
13
|
+
const [password, setPassword] = useState('');
|
|
14
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
15
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
16
|
+
const [submitting, setSubmitting] = useState(false);
|
|
17
|
+
const [success, setSuccess] = useState(false);
|
|
18
|
+
const [error, setError] = useState('');
|
|
19
|
+
|
|
20
|
+
if (!token) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
|
23
|
+
<div className="w-full max-w-md">
|
|
24
|
+
<div className="text-center mb-8">
|
|
25
|
+
<div className="mx-auto mb-4 w-14 h-14 bg-red-100 rounded-xl flex items-center justify-center">
|
|
26
|
+
<XCircle className="w-7 h-7 text-red-600" />
|
|
27
|
+
</div>
|
|
28
|
+
<h1 className="text-2xl font-bold text-gray-900">Invalid Reset Link</h1>
|
|
29
|
+
<p className="text-gray-600 mt-2">This password reset link is invalid or has expired.</p>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm text-center space-y-4">
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={() => onNavigate('/forgot-password')}
|
|
35
|
+
className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
|
36
|
+
>
|
|
37
|
+
Request New Reset Link
|
|
38
|
+
</button>
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={() => onNavigate('/login')}
|
|
42
|
+
className="w-full text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
|
43
|
+
>
|
|
44
|
+
Back to Sign In
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const passwordsMatch = password === confirmPassword;
|
|
53
|
+
const meetsLength = password.length >= 8;
|
|
54
|
+
const canSubmit = password && confirmPassword && passwordsMatch && meetsLength && !submitting;
|
|
55
|
+
|
|
56
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
if (!canSubmit) return;
|
|
59
|
+
|
|
60
|
+
setError('');
|
|
61
|
+
setSubmitting(true);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const result = await cmsApi<{ success: boolean }>('/auth/reset-password', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
body: JSON.stringify({ token, password }),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (result.error) {
|
|
70
|
+
setError(result.error);
|
|
71
|
+
} else {
|
|
72
|
+
setSuccess(true);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
setError('An unexpected error occurred. Please try again.');
|
|
76
|
+
} finally {
|
|
77
|
+
setSubmitting(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
|
83
|
+
<div className="w-full max-w-md">
|
|
84
|
+
<div className="text-center mb-8">
|
|
85
|
+
<div className="mx-auto mb-4 w-14 h-14 bg-blue-600 rounded-xl flex items-center justify-center">
|
|
86
|
+
<Shield className="w-7 h-7 text-white" />
|
|
87
|
+
</div>
|
|
88
|
+
<h1 className="text-2xl font-bold text-gray-900">
|
|
89
|
+
{success ? 'Password Reset' : 'Choose New Password'}
|
|
90
|
+
</h1>
|
|
91
|
+
<p className="text-gray-600 mt-2">
|
|
92
|
+
{success ? 'Your password has been updated' : 'Enter your new password below'}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm space-y-5">
|
|
97
|
+
{success ? (
|
|
98
|
+
<div className="text-center space-y-4">
|
|
99
|
+
<div className="mx-auto w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
|
100
|
+
<CheckCircle2 className="w-6 h-6 text-green-600" />
|
|
101
|
+
</div>
|
|
102
|
+
<p className="text-sm text-gray-600">
|
|
103
|
+
Your password has been successfully reset. All existing sessions have been revoked for security.
|
|
104
|
+
</p>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => onNavigate('/login')}
|
|
108
|
+
className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
|
109
|
+
>
|
|
110
|
+
Sign In with New Password
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
<form onSubmit={handleSubmit} className="space-y-5">
|
|
115
|
+
{error && (
|
|
116
|
+
<div className="flex items-start gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
117
|
+
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 shrink-0" />
|
|
118
|
+
<p className="text-sm text-red-800">{error}</p>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<div>
|
|
123
|
+
<label htmlFor="reset-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
|
124
|
+
New Password
|
|
125
|
+
</label>
|
|
126
|
+
<div className="relative">
|
|
127
|
+
<input
|
|
128
|
+
id="reset-password"
|
|
129
|
+
type={showPassword ? 'text' : 'password'}
|
|
130
|
+
value={password}
|
|
131
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
132
|
+
placeholder="Enter new password"
|
|
133
|
+
className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
134
|
+
required
|
|
135
|
+
autoFocus
|
|
136
|
+
autoComplete="new-password"
|
|
137
|
+
minLength={8}
|
|
138
|
+
/>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
142
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
143
|
+
tabIndex={-1}
|
|
144
|
+
>
|
|
145
|
+
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
{password && !meetsLength && (
|
|
149
|
+
<p className="mt-1 text-xs text-red-600">Password must be at least 8 characters</p>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div>
|
|
154
|
+
<label htmlFor="reset-confirm" className="block text-sm font-medium text-gray-700 mb-1.5">
|
|
155
|
+
Confirm Password
|
|
156
|
+
</label>
|
|
157
|
+
<input
|
|
158
|
+
id="reset-confirm"
|
|
159
|
+
type={showPassword ? 'text' : 'password'}
|
|
160
|
+
value={confirmPassword}
|
|
161
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
162
|
+
placeholder="Confirm new password"
|
|
163
|
+
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
164
|
+
required
|
|
165
|
+
autoComplete="new-password"
|
|
166
|
+
/>
|
|
167
|
+
{confirmPassword && !passwordsMatch && (
|
|
168
|
+
<p className="mt-1 text-xs text-red-600">Passwords do not match</p>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<button
|
|
173
|
+
type="submit"
|
|
174
|
+
disabled={!canSubmit}
|
|
175
|
+
className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
176
|
+
>
|
|
177
|
+
{submitting ? (
|
|
178
|
+
<>
|
|
179
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
180
|
+
Resetting...
|
|
181
|
+
</>
|
|
182
|
+
) : (
|
|
183
|
+
'Reset Password'
|
|
184
|
+
)}
|
|
185
|
+
</button>
|
|
186
|
+
</form>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|