@byline/admin 6.7.2 → 6.9.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 (102) hide show
  1. package/dist/fields/blocks/blocks-field.js +33 -3
  2. package/dist/fields/blocks/blocks-field.module.js +1 -0
  3. package/dist/fields/blocks/blocks-field_module.css +19 -0
  4. package/dist/forms/form-page-chrome.d.ts +3 -2
  5. package/dist/forms/form-page-chrome.js +13 -1
  6. package/dist/forms/form-renderer.d.ts +2 -1
  7. package/dist/forms/form-renderer.js +23 -17
  8. package/dist/forms/unavailable-blocks.d.ts +15 -0
  9. package/dist/forms/unavailable-blocks.js +38 -0
  10. package/dist/modules/admin-account/components/change-password.js +1 -1
  11. package/dist/modules/admin-account/components/container.js +18 -3
  12. package/dist/modules/admin-account/components/container.module.js +2 -0
  13. package/dist/modules/admin-account/components/container_module.css +7 -0
  14. package/dist/modules/admin-account/components/preferences.js +1 -1
  15. package/dist/modules/admin-account/components/update.js +1 -1
  16. package/dist/modules/admin-roles/components/create.js +1 -1
  17. package/dist/modules/admin-roles/components/permissions.js +1 -1
  18. package/dist/modules/admin-roles/components/update.js +1 -1
  19. package/dist/modules/admin-users/abilities.d.ts +1 -0
  20. package/dist/modules/admin-users/abilities.js +8 -1
  21. package/dist/modules/admin-users/components/create.js +1 -1
  22. package/dist/modules/admin-users/components/roles.js +1 -1
  23. package/dist/modules/admin-users/components/set-password.js +1 -1
  24. package/dist/modules/admin-users/components/update.js +1 -1
  25. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  26. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  27. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  28. package/dist/modules/auth/auth-maintenance.js +39 -0
  29. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  30. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  31. package/dist/modules/auth/components/passkey-browser.js +118 -0
  32. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  33. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  34. package/dist/modules/auth/components/passkey-manager.js +406 -0
  35. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  36. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  37. package/dist/modules/auth/components/sign-in-form.js +62 -3
  38. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  39. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  40. package/dist/modules/auth/index.d.ts +5 -0
  41. package/dist/modules/auth/index.js +3 -0
  42. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  43. package/dist/modules/auth/jwt-session-provider.js +135 -94
  44. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  45. package/dist/modules/auth/native-passkey-provider.js +618 -0
  46. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  47. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  48. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  49. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  50. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  51. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  52. package/dist/modules/auth/passkeys-repository.js +1 -0
  53. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  54. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  55. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  56. package/dist/services/admin-services-context.d.ts +1 -1
  57. package/dist/services/admin-services-types.d.ts +60 -0
  58. package/dist/store.d.ts +4 -0
  59. package/package.json +16 -9
  60. package/src/fields/blocks/blocks-field.module.css +23 -0
  61. package/src/fields/blocks/blocks-field.tsx +26 -2
  62. package/src/forms/form-page-chrome.tsx +19 -1
  63. package/src/forms/form-renderer-capabilities.test.tsx +6 -6
  64. package/src/forms/form-renderer.tsx +24 -15
  65. package/src/forms/unavailable-blocks.test.tsx +152 -0
  66. package/src/forms/unavailable-blocks.tsx +58 -0
  67. package/src/modules/admin-account/components/change-password.tsx +1 -1
  68. package/src/modules/admin-account/components/container.module.css +9 -0
  69. package/src/modules/admin-account/components/container.tsx +12 -3
  70. package/src/modules/admin-account/components/preferences.tsx +1 -1
  71. package/src/modules/admin-account/components/update.tsx +1 -1
  72. package/src/modules/admin-roles/components/create.tsx +1 -1
  73. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  74. package/src/modules/admin-roles/components/update.tsx +1 -1
  75. package/src/modules/admin-users/abilities.ts +7 -0
  76. package/src/modules/admin-users/components/create.tsx +1 -1
  77. package/src/modules/admin-users/components/roles.tsx +1 -1
  78. package/src/modules/admin-users/components/set-password.tsx +1 -1
  79. package/src/modules/admin-users/components/update.tsx +1 -1
  80. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  81. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  82. package/src/modules/auth/auth-maintenance.ts +62 -0
  83. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  84. package/src/modules/auth/components/passkey-browser.ts +139 -0
  85. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  86. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  87. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  88. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  89. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  90. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  91. package/src/modules/auth/index.ts +15 -0
  92. package/src/modules/auth/jwt-session-provider.ts +116 -44
  93. package/src/modules/auth/native-passkey-provider.ts +710 -0
  94. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  95. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  96. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  97. package/src/modules/auth/passkeys-repository.ts +97 -0
  98. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  99. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  100. package/src/services/admin-services-context.tsx +2 -0
  101. package/src/services/admin-services-types.ts +60 -0
  102. package/src/store.ts +4 -0
@@ -0,0 +1,386 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import type React from 'react'
12
+ import { useEffect, useId, useRef, useState } from 'react'
13
+
14
+ import type { PasskeyCredentialSummary } from '@byline/auth'
15
+ import { getAdminConfig } from '@byline/core'
16
+ import { useTranslation } from '@byline/i18n/react'
17
+ import {
18
+ Alert,
19
+ Button,
20
+ CloseIcon,
21
+ Drawer,
22
+ IconButton,
23
+ Input,
24
+ InputPassword,
25
+ Modal,
26
+ } from '@byline/ui/react'
27
+
28
+ import {
29
+ type PasskeyPageBinding,
30
+ useBylineAdminServices,
31
+ } from '../../../services/admin-services-context.js'
32
+ import { enrollPasskey, passkeyErrorKey, provePasskeyAction } from './passkey-browser.js'
33
+ import styles from './passkey-manager.module.css'
34
+
35
+ type Edit = { kind: 'enroll' } | { kind: 'rename' | 'remove'; credential: PasskeyCredentialSummary }
36
+
37
+ /** A host page's card component, so the passkey panel matches the cards around it. */
38
+ type PasskeySectionComponent = React.ComponentType<{
39
+ title: string
40
+ children: React.ReactNode
41
+ }>
42
+
43
+ export function PasskeyManager({
44
+ targetUserId,
45
+ targetIsSuperAdmin = false,
46
+ section: Section,
47
+ ownAccountNotice,
48
+ }: {
49
+ targetUserId: string
50
+ targetIsSuperAdmin?: boolean
51
+ /** Renders the panel inside the page's own card; defaults to a standalone card. */
52
+ section?: PasskeySectionComponent
53
+ /**
54
+ * Admin screens pass this to keep the admin (recovery) view even when the
55
+ * target is the signed-in user; it replaces the description in that case,
56
+ * typically pointing the user to their own account page.
57
+ */
58
+ ownAccountNotice?: React.ReactNode
59
+ }) {
60
+ const { passkeys } = useBylineAdminServices()
61
+ const { t } = useTranslation('byline-admin')
62
+ const id = useId()
63
+ const binding = useRef<PasskeyPageBinding | null>(null)
64
+ const [enabled, setEnabled] = useState(false)
65
+ const [loading, setLoading] = useState(true)
66
+ const [allowed, setAllowed] = useState(false)
67
+ const [self, setSelf] = useState(false)
68
+ const [credentials, setCredentials] = useState<PasskeyCredentialSummary[]>([])
69
+ const [edit, setEdit] = useState<Edit | null>(null)
70
+ const [name, setName] = useState('')
71
+ const [password, setPassword] = useState('')
72
+ const [pending, setPending] = useState(false)
73
+ const [error, setError] = useState<string | null>(null)
74
+ const [notice, setNotice] = useState<string | null>(null)
75
+ const mounted = useRef(false)
76
+ useEffect(() => {
77
+ let active = true
78
+ mounted.current = true
79
+ setAllowed(false)
80
+ setEnabled(false)
81
+ setLoading(true)
82
+ setError(null)
83
+ if (passkeys)
84
+ void (async () => {
85
+ if (!(await passkeys.capability()).enabled || !active) return
86
+ setEnabled(true)
87
+ const captured = passkeys.captureLogin()
88
+ const page = {
89
+ ...captured,
90
+ assertCurrent: () => {
91
+ if (!active) throw new Error('Passkey page closed')
92
+ captured.assertCurrent()
93
+ },
94
+ }
95
+ binding.current = page
96
+ const actor = await passkeys.managementContext(page)
97
+ const isSelf = actor.id === targetUserId
98
+ if (!isSelf && (!actor.canManagePasskeys || (targetIsSuperAdmin && !actor.isSuperAdmin)))
99
+ return
100
+ page.assertCurrent()
101
+ setSelf(isSelf)
102
+ setAllowed(true)
103
+ const rows = await passkeys.list({
104
+ data: { expectedSessionId: page.expectedSessionId!, targetUserId },
105
+ })
106
+ page.assertCurrent()
107
+ setCredentials(rows)
108
+ setLoading(false)
109
+ })().catch((error) => {
110
+ if (active) setError(passkeyErrorKey(error))
111
+ })
112
+ return () => {
113
+ active = false
114
+ mounted.current = false
115
+ binding.current = null
116
+ }
117
+ }, [passkeys, targetUserId, targetIsSuperAdmin])
118
+
119
+ function open(value: Edit) {
120
+ setEdit(value)
121
+ setName(value.kind === 'rename' ? value.credential.name : '')
122
+ setPassword('')
123
+ setError(null)
124
+ setNotice(null)
125
+ }
126
+ function close() {
127
+ if (pending) return
128
+ setEdit(null)
129
+ setPassword('')
130
+ setError(null)
131
+ }
132
+ async function perform(usePassword: boolean) {
133
+ const page = binding.current
134
+ if (!passkeys || !edit || !page?.expectedSessionId || pending) return
135
+ setPending(true)
136
+ setError(null)
137
+ const suppliedPassword = usePassword ? password : undefined
138
+ setPassword('')
139
+ try {
140
+ page.assertCurrent()
141
+ const expectedSessionId = page.expectedSessionId
142
+ if (edit.kind === 'rename') {
143
+ const updated = await passkeys.rename({
144
+ data: { expectedSessionId, credentialRecordId: edit.credential.id, name: name.trim() },
145
+ })
146
+ page.assertCurrent()
147
+ setCredentials((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
148
+ setNotice('passkeys.renamed')
149
+ } else if (edit.kind === 'enroll') {
150
+ const created = await enrollPasskey(passkeys, page, name.trim(), suppliedPassword)
151
+ page.assertCurrent()
152
+ setCredentials((rows) => [...rows, created])
153
+ setNotice('passkeys.added')
154
+ } else {
155
+ const grant = await provePasskeyAction(
156
+ passkeys,
157
+ page,
158
+ { purpose: 'remove', targetUserId, credentialRecordId: edit.credential.id },
159
+ suppliedPassword
160
+ )
161
+ page.assertCurrent()
162
+ const result = await passkeys.remove({
163
+ data: { expectedSessionId, grantId: grant.grantId },
164
+ })
165
+ if (result.signedOut) {
166
+ window.location.assign(getAdminConfig().routes.signIn)
167
+ return
168
+ }
169
+ page.assertCurrent()
170
+ setCredentials((rows) => rows.filter((row) => row.id !== edit.credential.id))
171
+ setNotice('passkeys.removed')
172
+ }
173
+ setEdit(null)
174
+ } catch (error) {
175
+ if (mounted.current) setError(passkeyErrorKey(error))
176
+ } finally {
177
+ if (mounted.current) setPending(false)
178
+ }
179
+ }
180
+
181
+ if (!passkeys || !enabled) return null
182
+ if (!allowed) return error ? <Alert intent="danger">{t(error)}</Alert> : null
183
+ // Admin screens keep the recovery view for the signed-in user too.
184
+ const selfService = self && ownAccountNotice === undefined
185
+ const fields = (
186
+ <>
187
+ {error && <Alert intent="danger">{t(error)}</Alert>}
188
+ {edit?.kind === 'remove' ? (
189
+ <p>{t('passkeys.removeWarning', { name: edit.credential.name })}</p>
190
+ ) : (
191
+ <Input
192
+ id={`${id}-name`}
193
+ name="passkey-name"
194
+ label={t('passkeys.name')}
195
+ value={name}
196
+ maxLength={80}
197
+ disabled={pending}
198
+ onChange={(event) => setName(event.currentTarget.value)}
199
+ />
200
+ )}
201
+ {edit?.kind !== 'rename' && (
202
+ <>
203
+ <p>{t('passkeys.proofDescription')}</p>
204
+ <InputPassword
205
+ id={`${id}-password`}
206
+ name="passkey-password"
207
+ label={t('common.fields.password')}
208
+ autoComplete="current-password"
209
+ value={password}
210
+ disabled={pending}
211
+ onChange={(event) => setPassword(event.currentTarget.value)}
212
+ showPasswordLabel={t('common.actions.showPassword')}
213
+ hidePasswordLabel={t('common.actions.hidePassword')}
214
+ />
215
+ </>
216
+ )}
217
+ {pending && <p role="status">{t('passkeys.waiting')}</p>}
218
+ </>
219
+ )
220
+ const actions = (
221
+ <>
222
+ <Button type="button" size="sm" intent="noeffect" disabled={pending} onClick={close}>
223
+ {t('common.actions.cancel')}
224
+ </Button>
225
+ {edit?.kind === 'rename' ? (
226
+ <Button
227
+ type="button"
228
+ size="sm"
229
+ disabled={pending || !name.trim()}
230
+ onClick={() => void perform(false)}
231
+ >
232
+ {t('common.actions.save')}
233
+ </Button>
234
+ ) : (
235
+ <>
236
+ <Button
237
+ type="button"
238
+ size="sm"
239
+ disabled={pending || !password || (edit?.kind === 'enroll' && !name.trim())}
240
+ onClick={() => void perform(true)}
241
+ >
242
+ {t('passkeys.usePassword')}
243
+ </Button>
244
+ <Button
245
+ type="button"
246
+ size="sm"
247
+ disabled={pending || (edit?.kind === 'enroll' && !name.trim())}
248
+ onClick={() => void perform(false)}
249
+ >
250
+ {t('passkeys.usePasskey')}
251
+ </Button>
252
+ </>
253
+ )}
254
+ </>
255
+ )
256
+ const body = (
257
+ <>
258
+ {self && ownAccountNotice !== undefined ? (
259
+ ownAccountNotice
260
+ ) : (
261
+ <p>{t(selfService ? 'passkeys.description' : 'passkeys.recoveryDescription')}</p>
262
+ )}
263
+ {!edit && error && <Alert intent="danger">{t(error)}</Alert>}
264
+ {notice && <p role="status">{t(notice)}</p>}
265
+ {loading ? (
266
+ <p role="status">{t('common.loading')}</p>
267
+ ) : credentials.length === 0 ? (
268
+ <p className={styles.empty}>{t('passkeys.empty')}</p>
269
+ ) : (
270
+ <ul className={styles.list}>
271
+ {credentials.map((credential) => (
272
+ <li key={credential.id} className={styles.row}>
273
+ <div>
274
+ <strong>{credential.name}</strong>
275
+ <p>
276
+ {t('passkeys.lastUsed')}:{' '}
277
+ {credential.lastUsedAt
278
+ ? new Date(credential.lastUsedAt).toLocaleString()
279
+ : t('common.never')}
280
+ </p>
281
+ </div>
282
+ <div className={styles.actions}>
283
+ {selfService && (
284
+ <Button
285
+ type="button"
286
+ size="sm"
287
+ variant="text"
288
+ onClick={() => open({ kind: 'rename', credential })}
289
+ aria-label={t('passkeys.renameNamed', { name: credential.name })}
290
+ >
291
+ {t('passkeys.rename')}
292
+ </Button>
293
+ )}
294
+ <Button
295
+ type="button"
296
+ size="sm"
297
+ intent="danger"
298
+ variant="text"
299
+ onClick={() => open({ kind: 'remove', credential })}
300
+ aria-label={t('passkeys.removeNamed', { name: credential.name })}
301
+ >
302
+ {t('passkeys.remove')}
303
+ </Button>
304
+ </div>
305
+ </li>
306
+ ))}
307
+ </ul>
308
+ )}
309
+ {selfService && (
310
+ <Button
311
+ type="button"
312
+ size="sm"
313
+ disabled={loading}
314
+ onClick={() => open({ kind: 'enroll' })}
315
+ className={styles.add}
316
+ >
317
+ {t('passkeys.add')}
318
+ </Button>
319
+ )}
320
+ <Modal
321
+ isOpen={edit?.kind === 'remove'}
322
+ onDismiss={close}
323
+ closeOnOverlayClick={false}
324
+ ariaLabel={t('passkeys.remove')}
325
+ >
326
+ <Modal.Container className={`byline-passkey-modal ${styles.modal}`}>
327
+ <Modal.Header>
328
+ <h2>{t('passkeys.remove')}</h2>
329
+ </Modal.Header>
330
+ <Modal.Content>{fields}</Modal.Content>
331
+ <Modal.Actions className={`byline-passkey-modal-actions ${styles['modal-actions']}`}>
332
+ {actions}
333
+ </Modal.Actions>
334
+ </Modal.Container>
335
+ </Modal>
336
+ <Drawer
337
+ id={`${id}-passkey-drawer`}
338
+ closeOnOverlayClick={false}
339
+ width="medium"
340
+ topOffset="46px"
341
+ isOpen={edit !== null && edit.kind !== 'remove'}
342
+ onDismiss={close}
343
+ className={`byline-passkey-drawer ${styles.drawer}`}
344
+ >
345
+ <Drawer.Container
346
+ aria-hidden={edit === null || edit.kind === 'remove'}
347
+ className={`byline-passkey-drawer-body ${styles['drawer-body']}`}
348
+ >
349
+ <Drawer.TopActions>
350
+ <button type="button" tabIndex={0} className={styles['drawer-skip']}>
351
+ no action
352
+ </button>
353
+ <IconButton aria-label={t('common.actions.close')} size="sm" onClick={close}>
354
+ <CloseIcon width="14px" height="14px" svgClassName="white-icon stroke-white" />
355
+ </IconButton>
356
+ </Drawer.TopActions>
357
+ <Drawer.Header>
358
+ <h2>{t(edit?.kind === 'rename' ? 'passkeys.rename' : 'passkeys.add')}</h2>
359
+ </Drawer.Header>
360
+ <Drawer.Content>
361
+ <div className={`byline-passkey-drawer-form ${styles['drawer-form']}`}>
362
+ {fields}
363
+ <div
364
+ className={`byline-passkey-drawer-actions ${styles['drawer-actions']} ${styles['modal-actions']}`}
365
+ >
366
+ {actions}
367
+ </div>
368
+ </div>
369
+ </Drawer.Content>
370
+ </Drawer.Container>
371
+ </Drawer>
372
+ </>
373
+ )
374
+ if (Section)
375
+ return (
376
+ <Section title={t('passkeys.title')}>
377
+ <div className="byline-passkey-manager">{body}</div>
378
+ </Section>
379
+ )
380
+ return (
381
+ <section className={`byline-passkey-manager ${styles.manager}`} aria-labelledby={`${id}-title`}>
382
+ <h2 id={`${id}-title`}>{t('passkeys.title')}</h2>
383
+ {body}
384
+ </section>
385
+ )
386
+ }
@@ -9,6 +9,9 @@
9
9
  * .byline-sign-in-actions — action row (optional Home link + submit button)
10
10
  * .byline-sign-in-home-link — left-side "Home" link (rendered when homeUrl is set)
11
11
  * .byline-sign-in-button — the submit button (min-width)
12
+ * .byline-sign-in-alternate — footer holding alternate sign-in methods (passkeys)
13
+ * .byline-sign-in-divider — "Or continue with" rule above the alternate methods
14
+ * .byline-passkey-sign-in-button — full-width passkey button
12
15
  */
13
16
 
14
17
  .card,
@@ -60,3 +63,37 @@
60
63
  /* Always floats right whether or not a Home link is present on the left. */
61
64
  margin-left: auto;
62
65
  }
66
+
67
+ .alternate,
68
+ :global(.byline-sign-in-alternate) {
69
+ flex-direction: column;
70
+ align-items: stretch;
71
+ gap: var(--spacing-16);
72
+ padding-bottom: var(--spacing-24);
73
+ }
74
+
75
+ .divider,
76
+ :global(.byline-sign-in-divider) {
77
+ display: flex;
78
+ align-items: center;
79
+ gap: var(--spacing-12);
80
+ color: var(--gray-500);
81
+ font-size: var(--font-size-xs);
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.04em;
84
+ }
85
+
86
+ .divider::before,
87
+ .divider::after,
88
+ :global(.byline-sign-in-divider)::before,
89
+ :global(.byline-sign-in-divider)::after {
90
+ content: "";
91
+ flex: 1;
92
+ border-top: var(--border-width-thin) var(--border-style-solid) var(--border-color, #8885);
93
+ }
94
+
95
+ .passkey-button,
96
+ :global(.byline-passkey-sign-in-button) {
97
+ width: 100%;
98
+ gap: var(--spacing-8);
99
+ }
@@ -0,0 +1,92 @@
1
+ import { act, type ReactNode } from 'react'
2
+
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ enabled: true,
8
+ signIn: vi.fn(),
9
+ passkey: vi.fn(),
10
+ services: {} as any,
11
+ }))
12
+ vi.mock('../../../services/admin-services-context.js', () => ({
13
+ useBylineAdminServices: () => mocks.services,
14
+ }))
15
+ vi.mock('@byline/i18n/react', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
16
+ vi.mock('@byline/core', () => ({ getAdminConfig: () => ({ routes: { admin: '/admin' } }) }))
17
+ vi.mock('./passkey-browser.js', () => ({
18
+ signInWithPasskey: mocks.passkey,
19
+ passkeyErrorKey: () => 'passkeys.errors.cancelled',
20
+ }))
21
+ vi.mock('@byline/ui/react', () => {
22
+ const Box = ({ children }: { children: ReactNode }) => <div>{children}</div>
23
+ return {
24
+ Card: Object.assign(Box, {
25
+ Header: Box,
26
+ Title: Box,
27
+ Description: Box,
28
+ Content: Box,
29
+ Footer: Box,
30
+ }),
31
+ Alert: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
32
+ Button: ({ children, disabled, onClick, type }: any) => (
33
+ <button type={type} disabled={disabled} onClick={onClick}>
34
+ {children}
35
+ </button>
36
+ ),
37
+ Input: ({ label, ...rest }: any) => <input aria-label={label} {...rest} />,
38
+ InputPassword: ({
39
+ label,
40
+ showPasswordLabel: _show,
41
+ hidePasswordLabel: _hide,
42
+ ...rest
43
+ }: any) => <input type="password" aria-label={label} {...rest} />,
44
+ LoaderEllipsis: () => null,
45
+ PasskeyIcon: () => null,
46
+ }
47
+ })
48
+
49
+ import { SignInForm } from './sign-in-form.js'
50
+
51
+ let root: Root
52
+ let container: HTMLDivElement
53
+ beforeEach(() => {
54
+ vi.resetAllMocks()
55
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
56
+ mocks.services = {
57
+ adminSignIn: mocks.signIn,
58
+ passkeys: { capability: async () => ({ enabled: mocks.enabled }) },
59
+ }
60
+ mocks.enabled = true
61
+ container = document.createElement('div')
62
+ document.body.appendChild(container)
63
+ root = createRoot(container)
64
+ })
65
+ afterEach(async () => {
66
+ await act(async () => root.unmount())
67
+ container.remove()
68
+ vi.unstubAllGlobals()
69
+ })
70
+ it('retains password sign-in when passkeys are disabled or the host has no passkey services', async () => {
71
+ mocks.enabled = false
72
+ await act(async () => root.render(<SignInForm />))
73
+ expect(container.querySelector('input[type="password"]')).not.toBeNull()
74
+ expect(container.textContent).not.toContain('passkeys.signIn')
75
+ delete mocks.services.passkeys
76
+ await act(async () => root.render(<SignInForm />))
77
+ expect(container.querySelector('form')).not.toBeNull()
78
+ })
79
+ it('keeps password choice visible and restores controls after passkey cancellation', async () => {
80
+ mocks.passkey.mockRejectedValue(new Error('cancelled'))
81
+ await act(async () => root.render(<SignInForm />))
82
+ const button = [...container.querySelectorAll('button')].find(
83
+ (button) => button.textContent === 'passkeys.signIn'
84
+ )!
85
+ expect(button).toBeTruthy()
86
+ expect(container.querySelector('input[type="password"]')).not.toBeNull()
87
+ await act(async () => button.click())
88
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe('passkeys.errors.cancelled')
89
+ expect(button.disabled).toBe(false)
90
+ expect(mocks.passkey).toHaveBeenCalledOnce()
91
+ expect(mocks.signIn).not.toHaveBeenCalled()
92
+ })
@@ -20,18 +20,28 @@
20
20
  * Stable override handles: `.byline-sign-in-card`, `.byline-sign-in-alert`,
21
21
  * `.byline-sign-in-form`, `.byline-sign-in-fields`,
22
22
  * `.byline-sign-in-actions`, `.byline-sign-in-button`,
23
- * `.byline-sign-in-home-link`.
23
+ * `.byline-sign-in-home-link`, `.byline-sign-in-alternate`,
24
+ * `.byline-sign-in-divider`, `.byline-passkey-sign-in-button`.
24
25
  */
25
26
 
26
- import { type FormEvent, useState } from 'react'
27
+ import { type FormEvent, useEffect, useRef, useState } from 'react'
27
28
 
28
29
  import { getAdminConfig } from '@byline/core'
29
30
  import { useTranslation } from '@byline/i18n/react'
30
- import { Alert, Button, Card, Input, InputPassword, LoaderEllipsis } from '@byline/ui/react'
31
+ import {
32
+ Alert,
33
+ Button,
34
+ Card,
35
+ Input,
36
+ InputPassword,
37
+ LoaderEllipsis,
38
+ PasskeyIcon,
39
+ } from '@byline/ui/react'
31
40
  import cx from 'clsx'
32
41
 
33
42
  import { useBylineAdminServices } from '../../../services/admin-services-context.js'
34
43
  import { normalizeRootRelativeRedirect, resolveSignInFormRedirect } from '../safe-redirect.js'
44
+ import { passkeyErrorKey, signInWithPasskey } from './passkey-browser.js'
35
45
  import styles from './sign-in-form.module.css'
36
46
 
37
47
  export interface SignInFormProps {
@@ -46,13 +56,47 @@ export interface SignInFormProps {
46
56
  }
47
57
 
48
58
  export function SignInForm({ redirectTo, homeUrl }: SignInFormProps) {
49
- const { adminSignIn } = useBylineAdminServices()
59
+ const { adminSignIn, passkeys } = useBylineAdminServices()
50
60
  const { t } = useTranslation('byline-admin')
51
61
  const [email, setEmail] = useState('')
52
62
  const [password, setPassword] = useState('')
53
63
  const [pending, setPending] = useState(false)
54
64
  const [error, setError] = useState<string | null>(null)
55
65
 
66
+ const mounted = useRef(false)
67
+ const [passkeysEnabled, setPasskeysEnabled] = useState(false)
68
+ useEffect(() => {
69
+ let active = true
70
+ mounted.current = true
71
+ void passkeys
72
+ ?.capability()
73
+ .then(({ enabled }) => {
74
+ if (active) setPasskeysEnabled(enabled)
75
+ })
76
+ .catch(() => {})
77
+ return () => {
78
+ active = false
79
+ mounted.current = false
80
+ }
81
+ }, [passkeys])
82
+
83
+ async function handlePasskey() {
84
+ if (!passkeys || pending) return
85
+ setPending(true)
86
+ setError(null)
87
+ try {
88
+ await signInWithPasskey(passkeys, () => mounted.current)
89
+ } catch (error) {
90
+ if (mounted.current) {
91
+ setError(t(passkeyErrorKey(error)))
92
+ setPending(false)
93
+ }
94
+ return
95
+ }
96
+ const destination = resolveSignInFormRedirect(redirectTo, getAdminConfig().routes.admin)
97
+ window.location.assign(normalizeRootRelativeRedirect(destination) ?? '/')
98
+ }
99
+
56
100
  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
57
101
  event.preventDefault()
58
102
  if (pending) return
@@ -144,6 +188,23 @@ export function SignInForm({ redirectTo, homeUrl }: SignInFormProps) {
144
188
  </div>
145
189
  </form>
146
190
  </Card.Content>
191
+ {passkeysEnabled && (
192
+ <Card.Footer className={cx('byline-sign-in-alternate', styles.alternate)}>
193
+ <div className={cx('byline-sign-in-divider', styles.divider)}>
194
+ <span>{t('auth.signIn.alternate')}</span>
195
+ </div>
196
+ <Button
197
+ type="button"
198
+ variant="outlined"
199
+ disabled={pending}
200
+ onClick={handlePasskey}
201
+ className={cx('byline-passkey-sign-in-button', styles['passkey-button'])}
202
+ >
203
+ <PasskeyIcon width="20px" height="20px" />
204
+ <span>{t('passkeys.signIn')}</span>
205
+ </Button>
206
+ </Card.Footer>
207
+ )}
147
208
  </Card>
148
209
  )
149
210
  }
@@ -21,11 +21,19 @@
21
21
  * against `@byline/auth` rather than added here.
22
22
  */
23
23
 
24
+ export { createAuthMaintenanceTasks } from './auth-maintenance.js'
24
25
  export {
25
26
  JwtSessionProvider,
26
27
  type JwtSessionProviderConfig,
27
28
  type NativeSessionEvent,
28
29
  } from './jwt-session-provider.js'
30
+ export { createPasskeyRequestLimiter } from './passkey-rate-limiter.js'
31
+ export {
32
+ assertPasskeyAttempt,
33
+ assertPasskeyBytes,
34
+ assertPasskeyClaim,
35
+ assertPasskeyTokenHash,
36
+ } from './passkey-storage-validation.js'
29
37
  export { hashPassword, verifyPassword } from './password.js'
30
38
  export { resolveActor } from './resolve-actor.js'
31
39
  export {
@@ -35,7 +43,14 @@ export {
35
43
  type SignInRateLimitStore,
36
44
  type SignInSecurityEvent,
37
45
  } from './sign-in-rate-limiter.js'
46
+ export type { AuthMaintenanceRepository } from './auth-maintenance-repository.js'
38
47
  export type { LoginSessionRow, LoginSessionsRepository } from './login-sessions-repository.js'
48
+ export type {
49
+ PasskeyAttemptRow,
50
+ PasskeyClaimInput,
51
+ PasskeyCredentialRow,
52
+ PasskeysRepository,
53
+ } from './passkeys-repository.js'
39
54
  export type {
40
55
  IssueRefreshTokenInput,
41
56
  RefreshTokenRow,