@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.
Files changed (49) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +23 -1
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +1 -1
  5. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  6. package/dist/components/Breadcrumbs.js +1 -0
  7. package/dist/components/Breadcrumbs.js.map +1 -1
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/layout/Sidebar.d.ts +7 -0
  13. package/dist/layout/Sidebar.d.ts.map +1 -1
  14. package/dist/layout/Sidebar.js +35 -11
  15. package/dist/layout/Sidebar.js.map +1 -1
  16. package/dist/views/DocumentEdit.d.ts.map +1 -1
  17. package/dist/views/DocumentEdit.js +49 -5
  18. package/dist/views/DocumentEdit.js.map +1 -1
  19. package/dist/views/ForgotPassword.d.ts +5 -0
  20. package/dist/views/ForgotPassword.d.ts.map +1 -0
  21. package/dist/views/ForgotPassword.js +41 -0
  22. package/dist/views/ForgotPassword.js.map +1 -0
  23. package/dist/views/ResetPassword.d.ts +6 -0
  24. package/dist/views/ResetPassword.d.ts.map +1 -0
  25. package/dist/views/ResetPassword.js +46 -0
  26. package/dist/views/ResetPassword.js.map +1 -0
  27. package/dist/views/ScriptTagEditor.d.ts +6 -0
  28. package/dist/views/ScriptTagEditor.d.ts.map +1 -0
  29. package/dist/views/ScriptTagEditor.js +109 -0
  30. package/dist/views/ScriptTagEditor.js.map +1 -0
  31. package/dist/views/ScriptTags.d.ts +5 -0
  32. package/dist/views/ScriptTags.d.ts.map +1 -0
  33. package/dist/views/ScriptTags.js +54 -0
  34. package/dist/views/ScriptTags.js.map +1 -0
  35. package/dist/views/Settings.d.ts +2 -1
  36. package/dist/views/Settings.d.ts.map +1 -1
  37. package/dist/views/Settings.js +31 -3
  38. package/dist/views/Settings.js.map +1 -1
  39. package/package.json +5 -3
  40. package/src/AdminRoot.tsx +26 -1
  41. package/src/components/Breadcrumbs.tsx +1 -0
  42. package/src/index.ts +4 -0
  43. package/src/layout/Sidebar.tsx +72 -22
  44. package/src/views/DocumentEdit.tsx +82 -4
  45. package/src/views/ForgotPassword.tsx +136 -0
  46. package/src/views/ResetPassword.tsx +192 -0
  47. package/src/views/ScriptTagEditor.tsx +361 -0
  48. package/src/views/ScriptTags.tsx +174 -0
  49. 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 payload = { ...values, ...seoData };
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
+ }