@byline/admin 6.7.2 → 6.8.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 (86) hide show
  1. package/dist/modules/admin-account/components/change-password.js +1 -1
  2. package/dist/modules/admin-account/components/container.js +18 -3
  3. package/dist/modules/admin-account/components/container.module.js +2 -0
  4. package/dist/modules/admin-account/components/container_module.css +7 -0
  5. package/dist/modules/admin-account/components/preferences.js +1 -1
  6. package/dist/modules/admin-account/components/update.js +1 -1
  7. package/dist/modules/admin-roles/components/create.js +1 -1
  8. package/dist/modules/admin-roles/components/permissions.js +1 -1
  9. package/dist/modules/admin-roles/components/update.js +1 -1
  10. package/dist/modules/admin-users/abilities.d.ts +1 -0
  11. package/dist/modules/admin-users/abilities.js +8 -1
  12. package/dist/modules/admin-users/components/create.js +1 -1
  13. package/dist/modules/admin-users/components/roles.js +1 -1
  14. package/dist/modules/admin-users/components/set-password.js +1 -1
  15. package/dist/modules/admin-users/components/update.js +1 -1
  16. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  17. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  18. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  19. package/dist/modules/auth/auth-maintenance.js +39 -0
  20. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  21. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  22. package/dist/modules/auth/components/passkey-browser.js +118 -0
  23. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  24. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  25. package/dist/modules/auth/components/passkey-manager.js +406 -0
  26. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  27. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  28. package/dist/modules/auth/components/sign-in-form.js +62 -3
  29. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  30. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  31. package/dist/modules/auth/index.d.ts +5 -0
  32. package/dist/modules/auth/index.js +3 -0
  33. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  34. package/dist/modules/auth/jwt-session-provider.js +135 -94
  35. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  36. package/dist/modules/auth/native-passkey-provider.js +618 -0
  37. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  38. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  39. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  40. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  41. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  42. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  43. package/dist/modules/auth/passkeys-repository.js +1 -0
  44. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  45. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  46. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  47. package/dist/services/admin-services-context.d.ts +1 -1
  48. package/dist/services/admin-services-types.d.ts +60 -0
  49. package/dist/store.d.ts +4 -0
  50. package/package.json +16 -9
  51. package/src/modules/admin-account/components/change-password.tsx +1 -1
  52. package/src/modules/admin-account/components/container.module.css +9 -0
  53. package/src/modules/admin-account/components/container.tsx +12 -3
  54. package/src/modules/admin-account/components/preferences.tsx +1 -1
  55. package/src/modules/admin-account/components/update.tsx +1 -1
  56. package/src/modules/admin-roles/components/create.tsx +1 -1
  57. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  58. package/src/modules/admin-roles/components/update.tsx +1 -1
  59. package/src/modules/admin-users/abilities.ts +7 -0
  60. package/src/modules/admin-users/components/create.tsx +1 -1
  61. package/src/modules/admin-users/components/roles.tsx +1 -1
  62. package/src/modules/admin-users/components/set-password.tsx +1 -1
  63. package/src/modules/admin-users/components/update.tsx +1 -1
  64. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  65. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  66. package/src/modules/auth/auth-maintenance.ts +62 -0
  67. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  68. package/src/modules/auth/components/passkey-browser.ts +139 -0
  69. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  70. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  71. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  72. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  73. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  74. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  75. package/src/modules/auth/index.ts +15 -0
  76. package/src/modules/auth/jwt-session-provider.ts +116 -44
  77. package/src/modules/auth/native-passkey-provider.ts +710 -0
  78. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  79. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  80. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  81. package/src/modules/auth/passkeys-repository.ts +97 -0
  82. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  83. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  84. package/src/services/admin-services-context.tsx +2 -0
  85. package/src/services/admin-services-types.ts +60 -0
  86. package/src/store.ts +4 -0
@@ -0,0 +1,189 @@
1
+ import { act, type ReactNode } from 'react'
2
+
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ const mocks = vi.hoisted(() => ({ services: {} as any, proof: vi.fn(), enroll: vi.fn() }))
7
+ vi.mock('../../../services/admin-services-context.js', () => ({
8
+ useBylineAdminServices: () => ({ passkeys: mocks.services }),
9
+ }))
10
+ vi.mock('@byline/i18n/react', () => ({
11
+ useTranslation: () => ({
12
+ t: (key: string, values?: { name?: string }) =>
13
+ `${key}${values?.name ? ` ${values.name}` : ''}`,
14
+ }),
15
+ }))
16
+ vi.mock('@byline/core', () => ({ getAdminConfig: () => ({ routes: { signIn: '/sign-in' } }) }))
17
+ vi.mock('./passkey-browser.js', () => ({
18
+ provePasskeyAction: mocks.proof,
19
+ enrollPasskey: mocks.enroll,
20
+ passkeyErrorKey: () => 'passkeys.errors.proof',
21
+ }))
22
+ vi.mock('@byline/ui/react', () => {
23
+ const Box = ({ children }: { children: ReactNode }) => <div>{children}</div>
24
+ const Modal = Object.assign(
25
+ ({ children, isOpen }: { children: ReactNode; isOpen: boolean }) =>
26
+ isOpen ? <div role="dialog">{children}</div> : null,
27
+ { Container: Box, Header: Box, Content: Box, Actions: Box }
28
+ )
29
+ const Drawer = Object.assign(
30
+ ({ children, isOpen }: { children: ReactNode; isOpen: boolean }) =>
31
+ isOpen ? <div role="dialog">{children}</div> : null,
32
+ { Container: Box, TopActions: Box, Header: Box, Content: Box }
33
+ )
34
+ return {
35
+ Modal,
36
+ Drawer,
37
+ CloseIcon: () => null,
38
+ IconButton: ({ children, onClick, ...rest }: any) => (
39
+ <button type="button" onClick={onClick} aria-label={rest['aria-label']}>
40
+ {children}
41
+ </button>
42
+ ),
43
+ Alert: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
44
+ Button: ({ children, onClick, disabled, ...rest }: any) => (
45
+ <button type="button" disabled={disabled} onClick={onClick} aria-label={rest['aria-label']}>
46
+ {children}
47
+ </button>
48
+ ),
49
+ Input: ({ label, ...rest }: any) => <input aria-label={label} {...rest} />,
50
+ InputPassword: ({
51
+ label,
52
+ showPasswordLabel: _show,
53
+ hidePasswordLabel: _hide,
54
+ ...rest
55
+ }: any) => <input type="password" aria-label={label} {...rest} />,
56
+ }
57
+ })
58
+
59
+ import { PasskeyManager } from './passkey-manager.js'
60
+
61
+ let root: Root
62
+ let container: HTMLDivElement
63
+ const key = {
64
+ id: 'record',
65
+ name: 'Security key',
66
+ createdAt: new Date(0).toISOString(),
67
+ lastUsedAt: null,
68
+ }
69
+ beforeEach(() => {
70
+ vi.resetAllMocks()
71
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
72
+ mocks.services = {
73
+ capability: vi.fn().mockResolvedValue({ enabled: true }),
74
+ captureLogin: vi.fn(() => ({ expectedSessionId: 'login', assertCurrent: vi.fn() })),
75
+ managementContext: vi
76
+ .fn()
77
+ .mockResolvedValue({ id: 'self', isSuperAdmin: false, canManagePasskeys: false }),
78
+ list: vi.fn().mockResolvedValue([key]),
79
+ rename: vi.fn(),
80
+ remove: vi.fn(),
81
+ }
82
+ mocks.proof.mockResolvedValue({
83
+ grantId: 'grant',
84
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
85
+ })
86
+ mocks.services.remove.mockResolvedValue({ signedOut: false })
87
+ container = document.createElement('div')
88
+ document.body.appendChild(container)
89
+ root = createRoot(container)
90
+ })
91
+ afterEach(async () => {
92
+ await act(async () => root.unmount())
93
+ container.remove()
94
+ vi.unstubAllGlobals()
95
+ })
96
+ const render = async (targetUserId = 'self', targetIsSuperAdmin = false) => {
97
+ await act(async () =>
98
+ root.render(
99
+ <PasskeyManager targetUserId={targetUserId} targetIsSuperAdmin={targetIsSuperAdmin} />
100
+ )
101
+ )
102
+ }
103
+ const click = async (label: string) => {
104
+ const button = [...container.querySelectorAll('button')].find(
105
+ (button) => button.textContent === label
106
+ )
107
+ expect(button).toBeTruthy()
108
+ await act(async () => button!.click())
109
+ }
110
+ describe('passkey manager controls', () => {
111
+ it('hides the flow when passkeys are disabled', async () => {
112
+ mocks.services.capability.mockResolvedValue({ enabled: false })
113
+ await render()
114
+ expect(container.innerHTML).toBe('')
115
+ expect(mocks.services.list).not.toHaveBeenCalled()
116
+ })
117
+ it('allows self-service enrollment and rename', async () => {
118
+ await render()
119
+ expect(container.textContent).toContain('passkeys.add')
120
+ expect(container.textContent).toContain('passkeys.rename')
121
+ expect(container.textContent).toContain('Security key')
122
+ })
123
+ it('keeps the admin view for the signed-in user when an admin screen passes a notice', async () => {
124
+ await act(async () =>
125
+ root.render(
126
+ <PasskeyManager targetUserId="self" ownAccountNotice={<p>own-account-notice</p>} />
127
+ )
128
+ )
129
+ expect(container.textContent).toContain('own-account-notice')
130
+ expect(container.textContent).toContain('Security key')
131
+ expect(container.textContent).toContain('passkeys.remove')
132
+ expect(container.textContent).not.toContain('passkeys.add')
133
+ expect(container.textContent).not.toContain('passkeys.rename')
134
+ expect(container.textContent).not.toContain('passkeys.description')
135
+ })
136
+ it('hides recovery without the ability and protects super-admin targets', async () => {
137
+ await render('other')
138
+ expect(container.innerHTML).toBe('')
139
+ expect(mocks.services.list).not.toHaveBeenCalled()
140
+ mocks.services.managementContext.mockResolvedValue({
141
+ id: 'self',
142
+ isSuperAdmin: false,
143
+ canManagePasskeys: true,
144
+ })
145
+ await render('super', true)
146
+ expect(container.innerHTML).toBe('')
147
+ })
148
+ it('offers only removal for an authorized recovery target', async () => {
149
+ mocks.services.managementContext.mockResolvedValue({
150
+ id: 'self',
151
+ isSuperAdmin: false,
152
+ canManagePasskeys: true,
153
+ })
154
+ await render('other')
155
+ expect(container.textContent).toContain('passkeys.remove')
156
+ expect(container.textContent).not.toContain('passkeys.add')
157
+ expect(container.textContent).not.toContain('passkeys.rename')
158
+ })
159
+ it('requires explicit confirmation before requesting proof and allows cancellation', async () => {
160
+ await render()
161
+ await click('passkeys.remove')
162
+ expect(container.querySelector('[role="dialog"]')?.textContent).toContain(
163
+ 'passkeys.removeWarning Security key'
164
+ )
165
+ expect(mocks.proof).not.toHaveBeenCalled()
166
+ await click('common.actions.cancel')
167
+ expect(container.querySelector('[role="dialog"]')).toBeNull()
168
+ expect(mocks.services.remove).not.toHaveBeenCalled()
169
+ })
170
+ it('never removes a key after a failed or wrong-account fresh proof', async () => {
171
+ mocks.proof.mockRejectedValue(new Error('wrong account'))
172
+ await render()
173
+ await click('passkeys.remove')
174
+ await click('passkeys.usePasskey')
175
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe('passkeys.errors.proof')
176
+ expect(mocks.services.remove).not.toHaveBeenCalled()
177
+ expect(mocks.proof).toHaveBeenCalledOnce()
178
+ })
179
+ it('shows completion only after confirmed removal and retains the actor view', async () => {
180
+ await render()
181
+ await click('passkeys.remove')
182
+ await click('passkeys.usePasskey')
183
+ expect(mocks.services.remove).toHaveBeenCalledWith({
184
+ data: { expectedSessionId: 'login', grantId: 'grant' },
185
+ })
186
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('passkeys.removed')
187
+ expect(container.textContent).not.toContain('Security key')
188
+ })
189
+ })
@@ -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
+ }