@actuate-media/cms-admin 0.27.0 → 0.29.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 (90) hide show
  1. package/dist/AdminRoot.d.ts +7 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +7 -2
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/account-security.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/account-security.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/account-security.render.test.js +98 -0
  8. package/dist/__tests__/views/account-security.render.test.js.map +1 -0
  9. package/dist/__tests__/views/login-mfa.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/login-mfa.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/login-mfa.render.test.js +73 -0
  12. package/dist/__tests__/views/login-mfa.render.test.js.map +1 -0
  13. package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/security-settings.render.test.js +175 -0
  16. package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  19. package/dist/components/Breadcrumbs.js +1 -0
  20. package/dist/components/Breadcrumbs.js.map +1 -1
  21. package/dist/views/Login.d.ts +16 -1
  22. package/dist/views/Login.d.ts.map +1 -1
  23. package/dist/views/Login.js +55 -3
  24. package/dist/views/Login.js.map +1 -1
  25. package/dist/views/Profile.d.ts +12 -0
  26. package/dist/views/Profile.d.ts.map +1 -0
  27. package/dist/views/Profile.js +16 -0
  28. package/dist/views/Profile.js.map +1 -0
  29. package/dist/views/Settings.d.ts.map +1 -1
  30. package/dist/views/Settings.js +3 -11
  31. package/dist/views/Settings.js.map +1 -1
  32. package/dist/views/profile/AccountSecurity.d.ts +2 -0
  33. package/dist/views/profile/AccountSecurity.d.ts.map +1 -0
  34. package/dist/views/profile/AccountSecurity.js +117 -0
  35. package/dist/views/profile/AccountSecurity.js.map +1 -0
  36. package/dist/views/profile/useMfaEnrollment.d.ts +35 -0
  37. package/dist/views/profile/useMfaEnrollment.d.ts.map +1 -0
  38. package/dist/views/profile/useMfaEnrollment.js +103 -0
  39. package/dist/views/profile/useMfaEnrollment.js.map +1 -0
  40. package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
  41. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
  42. package/dist/views/settings/AccessProtectionCard.js +20 -0
  43. package/dist/views/settings/AccessProtectionCard.js.map +1 -0
  44. package/dist/views/settings/AuthMfaCard.d.ts +11 -0
  45. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
  46. package/dist/views/settings/AuthMfaCard.js +34 -0
  47. package/dist/views/settings/AuthMfaCard.js.map +1 -0
  48. package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
  49. package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
  50. package/dist/views/settings/SecretsAuditCard.js +22 -0
  51. package/dist/views/settings/SecretsAuditCard.js.map +1 -0
  52. package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
  53. package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
  54. package/dist/views/settings/SecurityStatusCard.js +35 -0
  55. package/dist/views/settings/SecurityStatusCard.js.map +1 -0
  56. package/dist/views/settings/SecurityTab.d.ts +5 -0
  57. package/dist/views/settings/SecurityTab.d.ts.map +1 -0
  58. package/dist/views/settings/SecurityTab.js +41 -0
  59. package/dist/views/settings/SecurityTab.js.map +1 -0
  60. package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
  61. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
  62. package/dist/views/settings/SessionPolicyCard.js +21 -0
  63. package/dist/views/settings/SessionPolicyCard.js.map +1 -0
  64. package/dist/views/settings/securityPrimitives.d.ts +21 -0
  65. package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
  66. package/dist/views/settings/securityPrimitives.js +31 -0
  67. package/dist/views/settings/securityPrimitives.js.map +1 -0
  68. package/dist/views/settings/useSecuritySettings.d.ts +37 -0
  69. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
  70. package/dist/views/settings/useSecuritySettings.js +154 -0
  71. package/dist/views/settings/useSecuritySettings.js.map +1 -0
  72. package/package.json +3 -2
  73. package/src/AdminRoot.tsx +18 -0
  74. package/src/__tests__/views/account-security.render.test.tsx +140 -0
  75. package/src/__tests__/views/login-mfa.render.test.tsx +88 -0
  76. package/src/__tests__/views/security-settings.render.test.tsx +212 -0
  77. package/src/components/Breadcrumbs.tsx +1 -0
  78. package/src/views/Login.tsx +247 -94
  79. package/src/views/Profile.tsx +54 -0
  80. package/src/views/Settings.tsx +3 -33
  81. package/src/views/profile/AccountSecurity.tsx +406 -0
  82. package/src/views/profile/useMfaEnrollment.ts +120 -0
  83. package/src/views/settings/AccessProtectionCard.tsx +143 -0
  84. package/src/views/settings/AuthMfaCard.tsx +133 -0
  85. package/src/views/settings/SecretsAuditCard.tsx +129 -0
  86. package/src/views/settings/SecurityStatusCard.tsx +110 -0
  87. package/src/views/settings/SecurityTab.tsx +163 -0
  88. package/src/views/settings/SessionPolicyCard.tsx +123 -0
  89. package/src/views/settings/securityPrimitives.tsx +75 -0
  90. package/src/views/settings/useSecuritySettings.ts +197 -0
@@ -0,0 +1,212 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ // The Security tab reads `/security` (computed posture + summaries + editable
6
+ // policy), saves via PUT `/security`, and revokes sessions via POST.
7
+ let securityData: any
8
+
9
+ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?: unknown }) => {
10
+ const method = options?.method ?? 'GET'
11
+ if (endpoint === '/security' && method === 'GET') return { data: securityData, status: 200 }
12
+ if (endpoint === '/security' && method === 'PUT') {
13
+ const body = JSON.parse(String(options?.body ?? '{}'))
14
+ securityData = {
15
+ ...securityData,
16
+ settings: {
17
+ mfa: { required: body.mfa.required, gracePeriodDays: body.mfa.gracePeriodDays },
18
+ session: { maxConcurrentSessions: body.session.maxConcurrentSessions },
19
+ },
20
+ }
21
+ return { data: securityData, status: 200 }
22
+ }
23
+ if (endpoint === '/security/sessions/revoke-all' && method === 'POST')
24
+ return { data: { revoked: 2 }, status: 200 }
25
+ return { data: {}, status: 200 }
26
+ })
27
+
28
+ vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any) }))
29
+
30
+ const { SecurityTab } = await import('../../views/settings/SecurityTab.js')
31
+
32
+ function baseData(overrides: Record<string, unknown> = {}) {
33
+ return {
34
+ settings: {
35
+ mfa: { required: false, gracePeriodDays: 7 },
36
+ session: { maxConcurrentSessions: 5 },
37
+ },
38
+ status: {
39
+ status: 'needs_attention',
40
+ score: 88,
41
+ label: 'Needs attention',
42
+ description: 'Some recommended controls are missing.',
43
+ checks: [
44
+ { key: 'mfa', label: 'MFA coverage', status: 'warning', value: '1 of 2 admins' },
45
+ { key: 'csrf', label: 'CSRF protection', status: 'pass', value: 'Enabled' },
46
+ ],
47
+ warnings: [
48
+ {
49
+ id: 'mfa-not-required',
50
+ severity: 'warning',
51
+ category: 'auth',
52
+ title: 'MFA is not required in production',
53
+ description: 'Requiring MFA reduces account-takeover risk.',
54
+ },
55
+ ],
56
+ lastCheckedAt: '2026-06-01T00:00:00.000Z',
57
+ },
58
+ users: {
59
+ totalUsers: 3,
60
+ totalAdmins: 2,
61
+ withMfa: 1,
62
+ withoutMfa: 2,
63
+ adminsWithoutMfa: 1,
64
+ inactive: 0,
65
+ },
66
+ apiKeys: { active: 1, withoutExpiration: 0, broadScope: 0, staleUnused90d: 0 },
67
+ secrets: {
68
+ cmsSecret: 'pass',
69
+ encryptionKey: 'pass',
70
+ rateLimitBackend: 'distributed',
71
+ apiKeysHashed: true,
72
+ webhookSecretsEncrypted: true,
73
+ },
74
+ ipAllowlist: {
75
+ enabled: false,
76
+ source: 'none',
77
+ ranges: [],
78
+ currentIp: '1.2.3.4',
79
+ currentIpAllowed: null,
80
+ },
81
+ failedLogins24h: 0,
82
+ activeSessions: 2,
83
+ csrfEnabled: true,
84
+ uploadValidation: true,
85
+ auditEnabled: true,
86
+ sessionMaxAgeHours: 168,
87
+ viewerHasMfa: true,
88
+ environment: 'production',
89
+ sources: {
90
+ ipAllowlist: { source: 'config', editable: false },
91
+ csrf: { source: 'computed', editable: false },
92
+ rateLimiting: { source: 'environment', editable: false },
93
+ uploadValidation: { source: 'computed', editable: false },
94
+ cmsSecret: { source: 'environment', editable: false },
95
+ encryptionKey: { source: 'environment', editable: false },
96
+ audit: { source: 'computed', editable: false },
97
+ },
98
+ ...overrides,
99
+ }
100
+ }
101
+
102
+ beforeEach(() => {
103
+ cmsApi.mockClear()
104
+ securityData = baseData()
105
+ })
106
+
107
+ describe('SecurityTab', () => {
108
+ it('renders the status card and all security sections with a disabled save bar', async () => {
109
+ render(<SecurityTab role="ADMIN" />)
110
+
111
+ await screen.findByText('Security Status')
112
+ expect(screen.getByText('Authentication & MFA')).toBeTruthy()
113
+ expect(screen.getByText('Session Policy')).toBeTruthy()
114
+ expect(screen.getByText('Access & Request Protection')).toBeTruthy()
115
+ expect(screen.getByText('Secrets, Encryption & Audit')).toBeTruthy()
116
+ // Status badge + warning surfaced.
117
+ expect(screen.getByText('Needs attention')).toBeTruthy()
118
+ expect(screen.getByText('MFA is not required in production')).toBeTruthy()
119
+ expect(
120
+ (screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
121
+ ).toBe(true)
122
+ })
123
+
124
+ it('enables Save when concurrent-session cap changes and persists via PUT /security', async () => {
125
+ render(<SecurityTab role="ADMIN" />)
126
+ await screen.findByText('Security Status')
127
+
128
+ const select = screen.getByLabelText(/Maximum concurrent sessions/) as HTMLSelectElement
129
+ fireEvent.change(select, { target: { value: '3' } })
130
+
131
+ const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
132
+ expect(save.disabled).toBe(false)
133
+ fireEvent.click(save)
134
+
135
+ await waitFor(() =>
136
+ expect(cmsApi).toHaveBeenCalledWith(
137
+ '/security',
138
+ expect.objectContaining({
139
+ method: 'PUT',
140
+ body: expect.stringContaining('"maxConcurrentSessions":3'),
141
+ }),
142
+ ),
143
+ )
144
+ })
145
+
146
+ it('blocks enabling Require MFA (lockout-safe) when the acting admin has no MFA', async () => {
147
+ securityData = baseData({ viewerHasMfa: false })
148
+ render(<SecurityTab role="ADMIN" />)
149
+ await screen.findByText('Security Status')
150
+
151
+ const toggle = screen.getByRole('switch', {
152
+ name: 'Require MFA for admin users',
153
+ }) as HTMLButtonElement
154
+ // Toggle is disabled until the admin sets up their own MFA.
155
+ expect(toggle.disabled).toBe(true)
156
+ expect(screen.getByText(/Set up MFA on your own account/i)).toBeTruthy()
157
+ })
158
+
159
+ it('confirms before requiring MFA and then persists it (admin has MFA)', async () => {
160
+ render(<SecurityTab role="ADMIN" />)
161
+ await screen.findByText('Security Status')
162
+
163
+ fireEvent.click(screen.getByRole('switch', { name: 'Require MFA for admin users' }))
164
+ // Confirmation dialog appears; nothing saved yet.
165
+ const confirm = await screen.findByRole('button', { name: 'Require MFA' })
166
+ fireEvent.click(confirm)
167
+
168
+ const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
169
+ expect(save.disabled).toBe(false)
170
+ fireEvent.click(save)
171
+
172
+ await waitFor(() =>
173
+ expect(cmsApi).toHaveBeenCalledWith(
174
+ '/security',
175
+ expect.objectContaining({
176
+ method: 'PUT',
177
+ body: expect.stringContaining('"required":true'),
178
+ }),
179
+ ),
180
+ )
181
+ })
182
+
183
+ it('revokes other sessions through a confirmation flow', async () => {
184
+ render(<SecurityTab role="ADMIN" />)
185
+ await screen.findByText('Security Status')
186
+
187
+ fireEvent.click(screen.getByRole('button', { name: 'Revoke other sessions' }))
188
+ const confirm = await screen.findByRole('button', { name: 'Revoke sessions' })
189
+ fireEvent.click(confirm)
190
+
191
+ await waitFor(() =>
192
+ expect(cmsApi).toHaveBeenCalledWith(
193
+ '/security/sessions/revoke-all',
194
+ expect.objectContaining({ method: 'POST' }),
195
+ ),
196
+ )
197
+ await screen.findByText('Other sessions revoked.')
198
+ })
199
+
200
+ it('is read-only for non-admin roles', async () => {
201
+ render(<SecurityTab role="EDITOR" />)
202
+ await screen.findByText('Security Status')
203
+
204
+ expect(screen.getByText(/read-only access/i)).toBeTruthy()
205
+ const toggle = screen.getByRole('switch', {
206
+ name: 'Require MFA for admin users',
207
+ }) as HTMLButtonElement
208
+ expect(toggle.disabled).toBe(true)
209
+ const select = screen.getByLabelText(/Maximum concurrent sessions/) as HTMLSelectElement
210
+ expect(select.disabled).toBe(true)
211
+ })
212
+ })
@@ -14,6 +14,7 @@ const LABEL_MAP: Record<string, string> = {
14
14
  users: 'Users',
15
15
  'script-tags': 'Script Tags',
16
16
  settings: 'Settings',
17
+ profile: 'Your Profile',
17
18
  collections: 'Collections',
18
19
  submissions: 'Submissions',
19
20
  new: 'New',
@@ -25,6 +25,24 @@ export interface LoginProps {
25
25
  email: string,
26
26
  password: string,
27
27
  captchaToken?: string,
28
+ ) => Promise<{
29
+ success: boolean
30
+ error?: string
31
+ /** Set when the account has TOTP enabled — the password step succeeded but
32
+ * a second factor is still required. */
33
+ requiresTOTP?: boolean
34
+ /** Short-lived token tying the second-factor step to this password step. */
35
+ mfaPendingToken?: string
36
+ }>
37
+ /**
38
+ * Completes the second-factor step. Receives the `mfaPendingToken` from
39
+ * `onLogin` plus the user's authenticator code (or a one-time backup code).
40
+ * Required for accounts with MFA enabled; when omitted, the challenge screen
41
+ * explains that MFA sign-in isn't wired up.
42
+ */
43
+ onVerifyTotp?: (
44
+ mfaPendingToken: string,
45
+ code: string,
28
46
  ) => Promise<{ success: boolean; error?: string }>
29
47
  onNavigate?: (path: string) => void
30
48
  oauthProviders?: string[]
@@ -189,6 +207,7 @@ async function getCaptchaToken(provider: string, siteKey: string, action: string
189
207
 
190
208
  export function Login({
191
209
  onLogin,
210
+ onVerifyTotp,
192
211
  onNavigate,
193
212
  oauthProviders,
194
213
  captchaConfig,
@@ -200,6 +219,14 @@ export function Login({
200
219
  const [submitting, setSubmitting] = useState(false)
201
220
  const [error, setError] = useState('')
202
221
 
222
+ // Second-factor challenge state. When `mfaPendingToken` is set we swap the
223
+ // email/password form for the TOTP code entry.
224
+ const [mfaPendingToken, setMfaPendingToken] = useState<string | null>(null)
225
+ const [totpCode, setTotpCode] = useState('')
226
+ const [useBackupCode, setUseBackupCode] = useState(false)
227
+ const [totpError, setTotpError] = useState('')
228
+ const [verifying, setVerifying] = useState(false)
229
+
203
230
  useCaptchaScript(captchaConfig?.provider, captchaConfig?.siteKey)
204
231
 
205
232
  const canSubmit = email.trim() && password && !submitting
@@ -224,6 +251,13 @@ export function Login({
224
251
 
225
252
  const result = await onLogin(email.trim(), password, captchaToken)
226
253
 
254
+ if (result.requiresTOTP && result.mfaPendingToken) {
255
+ // Password verified — advance to the second-factor step.
256
+ setMfaPendingToken(result.mfaPendingToken)
257
+ setPassword('')
258
+ return
259
+ }
260
+
227
261
  if (!result.success) {
228
262
  setError(result.error ?? 'Invalid email or password')
229
263
  }
@@ -234,6 +268,39 @@ export function Login({
234
268
  }
235
269
  }
236
270
 
271
+ const handleVerifyTotp = async (e: FormEvent) => {
272
+ e.preventDefault()
273
+ const code = totpCode.trim()
274
+ if (!code || !mfaPendingToken) return
275
+
276
+ setTotpError('')
277
+
278
+ if (!onVerifyTotp) {
279
+ setTotpError('Two-factor sign-in is not available. Contact your administrator.')
280
+ return
281
+ }
282
+
283
+ setVerifying(true)
284
+ try {
285
+ const result = await onVerifyTotp(mfaPendingToken, code)
286
+ if (!result.success) {
287
+ setTotpError(result.error ?? 'That code was not accepted. Try again.')
288
+ }
289
+ } catch (err) {
290
+ setTotpError(err instanceof Error ? err.message : 'An unexpected error occurred')
291
+ } finally {
292
+ setVerifying(false)
293
+ }
294
+ }
295
+
296
+ const resetToPasswordStep = () => {
297
+ setMfaPendingToken(null)
298
+ setTotpCode('')
299
+ setUseBackupCode(false)
300
+ setTotpError('')
301
+ setError('')
302
+ }
303
+
237
304
  const handleOAuthClick = (provider: string) => {
238
305
  window.location.href = `/api/cms/auth/oauth/${provider}`
239
306
  }
@@ -269,116 +336,202 @@ export function Login({
269
336
  <p className="mt-2 text-gray-600">{brandTagline}</p>
270
337
  </div>
271
338
 
272
- <form
273
- onSubmit={handleSubmit}
274
- className="space-y-5 rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
275
- >
276
- {error && (
277
- <div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
278
- <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
279
- <p className="text-sm text-red-800">{error}</p>
339
+ {mfaPendingToken ? (
340
+ <form
341
+ onSubmit={handleVerifyTotp}
342
+ className="space-y-5 rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
343
+ >
344
+ <div>
345
+ <h2 className="text-lg font-semibold text-gray-900">Two-factor authentication</h2>
346
+ <p className="mt-1 text-sm text-gray-600">
347
+ {useBackupCode
348
+ ? 'Enter one of your one-time backup codes.'
349
+ : 'Enter the 6-digit code from your authenticator app.'}
350
+ </p>
280
351
  </div>
281
- )}
282
-
283
- <div>
284
- <label htmlFor="login-email" className="mb-1.5 block text-sm font-medium text-gray-700">
285
- Email Address
286
- </label>
287
- <input
288
- id="login-email"
289
- type="email"
290
- value={email}
291
- onChange={(e) => setEmail(e.target.value)}
292
- placeholder="admin@example.com"
293
- className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
294
- required
295
- autoFocus
296
- autoComplete="email"
297
- />
298
- </div>
299
-
300
- <div>
301
- <div className="mb-1.5 flex items-center justify-between">
302
- <label htmlFor="login-password" className="block text-sm font-medium text-gray-700">
303
- Password
352
+
353
+ {totpError && (
354
+ <div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
355
+ <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
356
+ <p className="text-sm text-red-800">{totpError}</p>
357
+ </div>
358
+ )}
359
+
360
+ <div>
361
+ <label htmlFor="totp-code" className="mb-1.5 block text-sm font-medium text-gray-700">
362
+ {useBackupCode ? 'Backup code' : 'Authentication code'}
304
363
  </label>
305
- {onNavigate && (
306
- <button
307
- type="button"
308
- onClick={() => onNavigate('/forgot-password')}
309
- className="text-xs font-medium text-blue-600 hover:text-blue-700"
310
- >
311
- Forgot Password?
312
- </button>
313
- )}
314
- </div>
315
- <div className="relative">
316
364
  <input
317
- id="login-password"
318
- type={showPassword ? 'text' : 'password'}
319
- value={password}
320
- onChange={(e) => setPassword(e.target.value)}
321
- placeholder="Enter your password"
322
- className="w-full rounded-lg border border-gray-300 px-3 py-2.5 pr-10 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
365
+ id="totp-code"
366
+ type="text"
367
+ inputMode={useBackupCode ? 'text' : 'numeric'}
368
+ autoComplete="one-time-code"
369
+ value={totpCode}
370
+ onChange={(e) =>
371
+ setTotpCode(
372
+ useBackupCode
373
+ ? e.target.value.trim()
374
+ : e.target.value.replace(/\D/g, '').slice(0, 6),
375
+ )
376
+ }
377
+ placeholder={useBackupCode ? 'abcd1234' : '123456'}
378
+ className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-center font-mono text-lg tracking-[0.3em] focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
379
+ autoFocus
323
380
  required
324
- autoComplete="current-password"
325
381
  />
382
+ </div>
383
+
384
+ <button
385
+ type="submit"
386
+ disabled={!totpCode.trim() || verifying}
387
+ className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-2.5 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
388
+ >
389
+ {verifying ? (
390
+ <>
391
+ <Loader2 className="h-4 w-4 animate-spin" />
392
+ Verifying...
393
+ </>
394
+ ) : (
395
+ 'Verify'
396
+ )}
397
+ </button>
398
+
399
+ <div className="flex items-center justify-between text-xs">
326
400
  <button
327
401
  type="button"
328
- onClick={() => setShowPassword(!showPassword)}
329
- className="absolute top-1/2 right-3 -translate-y-1/2 text-gray-400 hover:text-gray-600"
330
- tabIndex={-1}
402
+ onClick={() => {
403
+ setUseBackupCode((v) => !v)
404
+ setTotpCode('')
405
+ setTotpError('')
406
+ }}
407
+ className="font-medium text-blue-600 hover:text-blue-700"
331
408
  >
332
- {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
409
+ {useBackupCode ? 'Use authenticator app instead' : 'Use a backup code'}
410
+ </button>
411
+ <button
412
+ type="button"
413
+ onClick={resetToPasswordStep}
414
+ className="font-medium text-gray-500 hover:text-gray-700"
415
+ >
416
+ Back to sign in
333
417
  </button>
334
418
  </div>
335
- </div>
336
-
337
- <button
338
- type="submit"
339
- disabled={!canSubmit}
340
- className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-2.5 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
419
+ </form>
420
+ ) : (
421
+ <form
422
+ onSubmit={handleSubmit}
423
+ className="space-y-5 rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
341
424
  >
342
- {submitting ? (
343
- <>
344
- <Loader2 className="h-4 w-4 animate-spin" />
345
- Signing in...
346
- </>
347
- ) : (
348
- 'Sign In'
425
+ {error && (
426
+ <div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
427
+ <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
428
+ <p className="text-sm text-red-800">{error}</p>
429
+ </div>
349
430
  )}
350
- </button>
351
431
 
352
- {enabledProviders.length > 0 && (
353
- <>
432
+ <div>
433
+ <label
434
+ htmlFor="login-email"
435
+ className="mb-1.5 block text-sm font-medium text-gray-700"
436
+ >
437
+ Email Address
438
+ </label>
439
+ <input
440
+ id="login-email"
441
+ type="email"
442
+ value={email}
443
+ onChange={(e) => setEmail(e.target.value)}
444
+ placeholder="admin@example.com"
445
+ className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
446
+ required
447
+ autoFocus
448
+ autoComplete="email"
449
+ />
450
+ </div>
451
+
452
+ <div>
453
+ <div className="mb-1.5 flex items-center justify-between">
454
+ <label htmlFor="login-password" className="block text-sm font-medium text-gray-700">
455
+ Password
456
+ </label>
457
+ {onNavigate && (
458
+ <button
459
+ type="button"
460
+ onClick={() => onNavigate('/forgot-password')}
461
+ className="text-xs font-medium text-blue-600 hover:text-blue-700"
462
+ >
463
+ Forgot Password?
464
+ </button>
465
+ )}
466
+ </div>
354
467
  <div className="relative">
355
- <div className="absolute inset-0 flex items-center">
356
- <div className="w-full border-t border-gray-200" />
357
- </div>
358
- <div className="relative flex justify-center text-sm">
359
- <span className="bg-white px-3 text-gray-500">Or continue with</span>
360
- </div>
468
+ <input
469
+ id="login-password"
470
+ type={showPassword ? 'text' : 'password'}
471
+ value={password}
472
+ onChange={(e) => setPassword(e.target.value)}
473
+ placeholder="Enter your password"
474
+ className="w-full rounded-lg border border-gray-300 px-3 py-2.5 pr-10 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
475
+ required
476
+ autoComplete="current-password"
477
+ />
478
+ <button
479
+ type="button"
480
+ onClick={() => setShowPassword(!showPassword)}
481
+ className="absolute top-1/2 right-3 -translate-y-1/2 text-gray-400 hover:text-gray-600"
482
+ tabIndex={-1}
483
+ >
484
+ {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
485
+ </button>
361
486
  </div>
487
+ </div>
362
488
 
363
- <div className="grid gap-3">
364
- {enabledProviders.map((provider) => {
365
- const colors = OAUTH_COLORS[provider] ?? OAUTH_COLORS.google
366
- return (
367
- <button
368
- key={provider}
369
- type="button"
370
- onClick={() => handleOAuthClick(provider)}
371
- className={`flex w-full items-center justify-center gap-3 border px-4 py-2.5 ${colors?.border ?? ''} ${colors?.bg ?? ''} ${colors?.text ?? ''} rounded-lg text-sm font-medium ${colors?.hover ?? ''} transition-colors`}
372
- >
373
- <OAuthIcon provider={provider} />
374
- {OAUTH_LABELS[provider] ?? provider}
375
- </button>
376
- )
377
- })}
378
- </div>
379
- </>
380
- )}
381
- </form>
489
+ <button
490
+ type="submit"
491
+ disabled={!canSubmit}
492
+ className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-2.5 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
493
+ >
494
+ {submitting ? (
495
+ <>
496
+ <Loader2 className="h-4 w-4 animate-spin" />
497
+ Signing in...
498
+ </>
499
+ ) : (
500
+ 'Sign In'
501
+ )}
502
+ </button>
503
+
504
+ {enabledProviders.length > 0 && (
505
+ <>
506
+ <div className="relative">
507
+ <div className="absolute inset-0 flex items-center">
508
+ <div className="w-full border-t border-gray-200" />
509
+ </div>
510
+ <div className="relative flex justify-center text-sm">
511
+ <span className="bg-white px-3 text-gray-500">Or continue with</span>
512
+ </div>
513
+ </div>
514
+
515
+ <div className="grid gap-3">
516
+ {enabledProviders.map((provider) => {
517
+ const colors = OAUTH_COLORS[provider] ?? OAUTH_COLORS.google
518
+ return (
519
+ <button
520
+ key={provider}
521
+ type="button"
522
+ onClick={() => handleOAuthClick(provider)}
523
+ className={`flex w-full items-center justify-center gap-3 border px-4 py-2.5 ${colors?.border ?? ''} ${colors?.bg ?? ''} ${colors?.text ?? ''} rounded-lg text-sm font-medium ${colors?.hover ?? ''} transition-colors`}
524
+ >
525
+ <OAuthIcon provider={provider} />
526
+ {OAUTH_LABELS[provider] ?? provider}
527
+ </button>
528
+ )
529
+ })}
530
+ </div>
531
+ </>
532
+ )}
533
+ </form>
534
+ )}
382
535
  </div>
383
536
  </div>
384
537
  )
@@ -0,0 +1,54 @@
1
+ 'use client'
2
+
3
+ import { useId } from 'react'
4
+ import { SecurityCard } from './settings/securityPrimitives.js'
5
+ import { AccountSecurity } from './profile/AccountSecurity.js'
6
+
7
+ export interface ProfileProps {
8
+ onNavigate?: (path: string) => void
9
+ session?: { user?: { name?: string; email?: string; role?: string } }
10
+ }
11
+
12
+ function roleLabel(role?: string): string {
13
+ if (!role) return 'Member'
14
+ return role.charAt(0).toUpperCase() + role.slice(1).toLowerCase()
15
+ }
16
+
17
+ export function Profile({ session }: ProfileProps = {}) {
18
+ const accountHeadingId = useId()
19
+ const user = session?.user
20
+
21
+ return (
22
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8">
23
+ <div className="mb-4">
24
+ <h1 className="mb-1 text-2xl font-semibold text-gray-900">Your profile</h1>
25
+ <p className="text-sm text-gray-600">Manage your account details and sign-in security.</p>
26
+ </div>
27
+
28
+ <div className="grid max-w-3xl gap-4">
29
+ <SecurityCard
30
+ headingId={accountHeadingId}
31
+ title="Account"
32
+ description="Your identity within this CMS. Contact an administrator to change your role."
33
+ >
34
+ <dl className="divide-border divide-y text-sm">
35
+ <div className="flex items-center justify-between gap-3 py-2">
36
+ <dt className="text-muted-foreground">Name</dt>
37
+ <dd className="text-foreground">{user?.name ?? '—'}</dd>
38
+ </div>
39
+ <div className="flex items-center justify-between gap-3 py-2">
40
+ <dt className="text-muted-foreground">Email</dt>
41
+ <dd className="text-foreground">{user?.email ?? '—'}</dd>
42
+ </div>
43
+ <div className="flex items-center justify-between gap-3 py-2">
44
+ <dt className="text-muted-foreground">Role</dt>
45
+ <dd className="text-foreground">{roleLabel(user?.role)}</dd>
46
+ </div>
47
+ </dl>
48
+ </SecurityCard>
49
+
50
+ <AccountSecurity />
51
+ </div>
52
+ </div>
53
+ )
54
+ }