@byline/admin 6.7.1 → 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 (163) 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 +31 -24
  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
  87. package/dist/exports-parity.test.node.js +0 -37
  88. package/dist/fields/array/array-field.jsx +0 -209
  89. package/dist/fields/blocks/blocks-field.jsx +0 -248
  90. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  91. package/dist/fields/code/code-editor.jsx +0 -211
  92. package/dist/fields/code/code-field.jsx +0 -109
  93. package/dist/fields/column-formatter.jsx +0 -25
  94. package/dist/fields/date-time-formatter.jsx +0 -18
  95. package/dist/fields/datetime/datetime-field.jsx +0 -22
  96. package/dist/fields/draggable-context-menu.jsx +0 -50
  97. package/dist/fields/field-admin.test.node.js +0 -40
  98. package/dist/fields/field-renderer.jsx +0 -136
  99. package/dist/fields/field-services-context.jsx +0 -17
  100. package/dist/fields/file/file-field.jsx +0 -176
  101. package/dist/fields/file/file-upload-field.jsx +0 -89
  102. package/dist/fields/group/group-field.jsx +0 -46
  103. package/dist/fields/image/image-field.jsx +0 -183
  104. package/dist/fields/image/image-upload-field.jsx +0 -157
  105. package/dist/fields/local-date-time.jsx +0 -69
  106. package/dist/fields/locale-badge.jsx +0 -19
  107. package/dist/fields/numerical/numerical-field.jsx +0 -93
  108. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  109. package/dist/fields/relation/relation-display.jsx +0 -118
  110. package/dist/fields/relation/relation-field.jsx +0 -108
  111. package/dist/fields/relation/relation-many-field.jsx +0 -142
  112. package/dist/fields/relation/relation-picker.jsx +0 -240
  113. package/dist/fields/relation/relation-summary.jsx +0 -45
  114. package/dist/fields/select/select-field.jsx +0 -27
  115. package/dist/fields/sortable-item.jsx +0 -54
  116. package/dist/fields/text/text-field.jsx +0 -79
  117. package/dist/fields/text-area/text-area-field.jsx +0 -79
  118. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  119. package/dist/forms/available-locales-widget.jsx +0 -63
  120. package/dist/forms/document-actions.jsx +0 -486
  121. package/dist/forms/form-context.jsx +0 -598
  122. package/dist/forms/form-modals.jsx +0 -128
  123. package/dist/forms/form-renderer.jsx +0 -532
  124. package/dist/forms/form-status-display.jsx +0 -69
  125. package/dist/forms/navigation-guard.jsx +0 -68
  126. package/dist/forms/nested-path.test.node.js +0 -115
  127. package/dist/forms/path-widget.jsx +0 -125
  128. package/dist/forms/pending-uploads.test.node.js +0 -19
  129. package/dist/forms/repeating-items.test.node.js +0 -29
  130. package/dist/forms/scheduled-publication-control.jsx +0 -389
  131. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  132. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  133. package/dist/forms/status-transitions.test.node.js +0 -76
  134. package/dist/forms/tree-placement-widget.jsx +0 -160
  135. package/dist/forms/upload-executor.test.node.js +0 -368
  136. package/dist/forms/use-form-layout.test.node.js +0 -69
  137. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  138. package/dist/modules/admin-account/components/container.jsx +0 -164
  139. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  140. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  141. package/dist/modules/admin-account/components/update.jsx +0 -186
  142. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  143. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  144. package/dist/modules/admin-roles/components/create.jsx +0 -155
  145. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  146. package/dist/modules/admin-roles/components/update.jsx +0 -148
  147. package/dist/modules/admin-users/components/create.jsx +0 -206
  148. package/dist/modules/admin-users/components/roles.jsx +0 -126
  149. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  150. package/dist/modules/admin-users/components/update.jsx +0 -215
  151. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  152. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  153. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  154. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  155. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  156. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  157. package/dist/presentation/group.jsx +0 -26
  158. package/dist/presentation/row.jsx +0 -23
  159. package/dist/presentation/tabs.jsx +0 -39
  160. package/dist/services/admin-services-context.jsx +0 -17
  161. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  162. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  163. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -160,7 +160,7 @@ export function SetPassword({ user, onClose, onSuccess }: SetPasswordProps) {
160
160
  <div className={cx('byline-user-set-password-actions', styles.actions)}>
161
161
  <Button
162
162
  type="button"
163
- intent="secondary"
163
+ intent="noeffect"
164
164
  size="sm"
165
165
  onClick={onClose}
166
166
  className={cx('byline-user-set-password-action', styles.action)}
@@ -295,7 +295,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
295
295
  <div className={cx('byline-user-update-actions', styles.actions)}>
296
296
  <Button
297
297
  type="button"
298
- intent="secondary"
298
+ intent="noeffect"
299
299
  size="sm"
300
300
  onClick={onClose}
301
301
  className={cx('byline-user-update-action', styles.action)}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /** Bounded housekeeping; expiry and revocation remain independent authorization checks. */
10
+ export interface AuthMaintenanceRepository {
11
+ /** Remove at most 100 attempts expired or consumed on/before the supplied grace cutoff. */
12
+ purgeAttempts(before: Date): Promise<number>
13
+ /** Candidate only: eligibility must be checked again under its owning account lock. */
14
+ findTerminalLogin(before: Date): Promise<{ id: string; adminUserId: string } | null>
15
+ /**
16
+ * Requires the owning account lock. Re-read eligibility, delete at most 100 refresh
17
+ * children, then delete the terminal parent only if no children remain. Never cascade.
18
+ */
19
+ pruneTerminalLogin(input: {
20
+ id: string
21
+ adminUserId: string
22
+ before: Date
23
+ }): Promise<{ refreshDeleted: number; loginDeleted: boolean }>
24
+ /** Remove at most 100 legacy null-sid rows expired on/before the retention cutoff. */
25
+ purgeLegacyRefreshTokens(before: Date): Promise<number>
26
+ }
@@ -0,0 +1,110 @@
1
+ import type { RecurringTaskContext } from '@byline/core'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import { createAuthMaintenanceTasks } from './auth-maintenance.js'
5
+ import type { AdminStore } from '../../store.js'
6
+
7
+ let store: AdminStore
8
+ let context: RecurringTaskContext
9
+ const now = new Date('2026-10-16T12:00:00Z')
10
+ beforeEach(() => {
11
+ store = {
12
+ authMaintenance: {
13
+ purgeAttempts: vi.fn().mockResolvedValue(0),
14
+ findTerminalLogin: vi.fn().mockResolvedValue(null),
15
+ pruneTerminalLogin: vi.fn(),
16
+ purgeLegacyRefreshTokens: vi.fn().mockResolvedValue(0),
17
+ },
18
+ } as unknown as AdminStore
19
+ store.withSessionLock = vi.fn(async (_id, work) => work(store, null))
20
+ context = {
21
+ taskName: 'test',
22
+ scheduledFor: now,
23
+ signal: new AbortController().signal,
24
+ heartbeat: vi.fn().mockResolvedValue(undefined),
25
+ logger: {},
26
+ } as unknown as RecurringTaskContext
27
+ })
28
+ afterEach(() => vi.restoreAllMocks())
29
+ describe('bounded auth maintenance tasks', () => {
30
+ it('registers three one-minute leased tasks without doing any work', () => {
31
+ const tasks = createAuthMaintenanceTasks(store)
32
+ expect(tasks.map((t) => t.name)).toEqual([
33
+ 'auth.maintenance.attempts',
34
+ 'auth.maintenance.logins',
35
+ 'auth.maintenance.legacy-refresh',
36
+ ])
37
+ expect(tasks.every((t) => t.intervalMs === 60_000 && t.leaseMs === 60_000)).toBe(true)
38
+ expect(store.authMaintenance.purgeAttempts).not.toHaveBeenCalled()
39
+ })
40
+ it('uses separate five-minute and 30-day cutoffs', async () => {
41
+ const tasks = createAuthMaintenanceTasks(store, () => now)
42
+ for (const task of tasks) expect(await task.run(context)).toEqual({ workRemaining: false })
43
+ expect(store.authMaintenance.purgeAttempts).toHaveBeenCalledWith(
44
+ new Date(now.getTime() - 300_000)
45
+ )
46
+ expect(store.authMaintenance.findTerminalLogin).toHaveBeenCalledWith(
47
+ new Date(now.getTime() - 30 * 86_400_000)
48
+ )
49
+ expect(store.authMaintenance.purgeLegacyRefreshTokens).toHaveBeenCalledWith(
50
+ new Date(now.getTime() - 30 * 86_400_000)
51
+ )
52
+ })
53
+ it('stops at 32 full batches and heartbeats before each', async () => {
54
+ vi.mocked(store.authMaintenance.purgeAttempts).mockResolvedValue(100)
55
+ const [task] = createAuthMaintenanceTasks(store)
56
+ expect(await task!.run(context)).toEqual({ workRemaining: true })
57
+ expect(store.authMaintenance.purgeAttempts).toHaveBeenCalledTimes(32)
58
+ expect(context.heartbeat).toHaveBeenCalledTimes(32)
59
+ })
60
+ it('stops when the elapsed-time budget is reached', async () => {
61
+ vi.spyOn(performance, 'now')
62
+ .mockReturnValueOnce(0)
63
+ .mockReturnValueOnce(0)
64
+ .mockReturnValue(30_000)
65
+ vi.mocked(store.authMaintenance.purgeAttempts).mockResolvedValue(100)
66
+ expect(await createAuthMaintenanceTasks(store)[0]!.run(context)).toEqual({
67
+ workRemaining: true,
68
+ })
69
+ expect(store.authMaintenance.purgeAttempts).toHaveBeenCalledOnce()
70
+ })
71
+ it.each(['before', 'heartbeat'])('honours abort %s batch admission', async (phase) => {
72
+ const abort = new AbortController()
73
+ context.signal = abort.signal
74
+ if (phase === 'before') abort.abort(new Error('stopped'))
75
+ else
76
+ vi.mocked(context.heartbeat).mockImplementation(async () => {
77
+ abort.abort(new Error('stopped'))
78
+ })
79
+ await expect(createAuthMaintenanceTasks(store)[0]!.run(context)).rejects.toThrow('stopped')
80
+ expect(store.authMaintenance.purgeAttempts).not.toHaveBeenCalled()
81
+ })
82
+ it('propagates lease loss before cleanup and repository errors to the runner', async () => {
83
+ vi.mocked(context.heartbeat).mockRejectedValueOnce(new Error('lease lost'))
84
+ await expect(createAuthMaintenanceTasks(store)[0]!.run(context)).rejects.toThrow('lease lost')
85
+ expect(store.authMaintenance.purgeAttempts).not.toHaveBeenCalled()
86
+ vi.mocked(store.authMaintenance.purgeAttempts).mockRejectedValue(
87
+ new Error('storage unavailable')
88
+ )
89
+ await expect(createAuthMaintenanceTasks(store)[0]!.run(context)).rejects.toThrow(
90
+ 'storage unavailable'
91
+ )
92
+ })
93
+ it('uses the scoped store under the candidate account lock and rescans between batches', async () => {
94
+ const scoped = { authMaintenance: { pruneTerminalLogin: vi.fn() } } as unknown as AdminStore
95
+ vi.mocked(store.withSessionLock).mockImplementation(async (_id, work) => work(scoped, null))
96
+ vi.mocked(store.authMaintenance.findTerminalLogin)
97
+ .mockResolvedValueOnce({ id: 'login', adminUserId: 'actor' })
98
+ .mockResolvedValue(null)
99
+ expect(await createAuthMaintenanceTasks(store, () => now)[1]!.run(context)).toEqual({
100
+ workRemaining: false,
101
+ })
102
+ expect(store.withSessionLock).toHaveBeenCalledWith('actor', expect.any(Function))
103
+ expect(scoped.authMaintenance.pruneTerminalLogin).toHaveBeenCalledWith({
104
+ id: 'login',
105
+ adminUserId: 'actor',
106
+ before: new Date(now.getTime() - 30 * 86_400_000),
107
+ })
108
+ expect(store.authMaintenance.pruneTerminalLogin).not.toHaveBeenCalled()
109
+ })
110
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { RecurringTaskDefinition } from '@byline/core'
10
+
11
+ import type { AdminStore } from '../../store.js'
12
+
13
+ const DAY = 86_400_000
14
+
15
+ /** Register for native sessions even on password-only sites; configuration never runs cleanup. */
16
+ export function createAuthMaintenanceTasks(
17
+ store: AdminStore,
18
+ now: () => Date = () => new Date()
19
+ ): RecurringTaskDefinition[] {
20
+ const task = (
21
+ name: string,
22
+ retention: number,
23
+ batch: (cutoff: Date) => Promise<boolean>
24
+ ): RecurringTaskDefinition => ({
25
+ name: `auth.maintenance.${name}`,
26
+ intervalMs: 60_000,
27
+ leaseMs: 60_000,
28
+ async run(context) {
29
+ const started = performance.now()
30
+ const cutoff = new Date(now().getTime() - retention)
31
+ for (let n = 0; n < 32 && performance.now() - started < 30_000; n++) {
32
+ context.signal.throwIfAborted()
33
+ await context.heartbeat()
34
+ context.signal.throwIfAborted()
35
+ if (!(await batch(cutoff))) return { workRemaining: false }
36
+ }
37
+ return { workRemaining: true }
38
+ },
39
+ })
40
+ return [
41
+ task(
42
+ 'attempts',
43
+ 300_000,
44
+ async (cutoff) => (await store.authMaintenance.purgeAttempts(cutoff)) === 100
45
+ ),
46
+ task('logins', 30 * DAY, async (cutoff) => {
47
+ const candidate = await store.authMaintenance.findTerminalLogin(cutoff)
48
+ if (!candidate) return false
49
+ await store.withSessionLock(candidate.adminUserId, async (scoped) => {
50
+ await scoped.authMaintenance.pruneTerminalLogin({ ...candidate, before: cutoff })
51
+ })
52
+ // A candidate is not a cursor. Re-scan after each bounded transaction so
53
+ // a login with many refresh predecessors drains without a long-held lock.
54
+ return true
55
+ }),
56
+ task(
57
+ 'legacy-refresh',
58
+ 30 * DAY,
59
+ async (cutoff) => (await store.authMaintenance.purgeLegacyRefreshTokens(cutoff)) === 100
60
+ ),
61
+ ]
62
+ }
@@ -0,0 +1,188 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import type {
4
+ PasskeyAdminServices,
5
+ PasskeyPageBinding,
6
+ } from '../../../services/admin-services-types.js'
7
+
8
+ const browser = vi.hoisted(() => ({
9
+ supported: vi.fn(),
10
+ authentication: vi.fn(),
11
+ registration: vi.fn(),
12
+ }))
13
+ vi.mock('@simplewebauthn/browser', () => ({
14
+ browserSupportsWebAuthn: browser.supported,
15
+ startAuthentication: browser.authentication,
16
+ startRegistration: browser.registration,
17
+ }))
18
+
19
+ import {
20
+ enrollPasskey,
21
+ passkeyErrorKey,
22
+ provePasskeyAction,
23
+ signInWithPasskey,
24
+ } from './passkey-browser.js'
25
+
26
+ let services: PasskeyAdminServices
27
+ let binding: PasskeyPageBinding
28
+ const expiresAt = () => new Date(Date.now() + 60_000).toISOString()
29
+ beforeEach(() => {
30
+ vi.resetAllMocks()
31
+ browser.supported.mockReturnValue(true)
32
+ browser.authentication.mockResolvedValue({ id: 'assertion' })
33
+ browser.registration.mockResolvedValue({ id: 'attestation' })
34
+ binding = { expectedSessionId: 'login-A', assertCurrent: vi.fn() }
35
+ const ceremony = () => ({ ceremonyId: 'attempt', expiresAt: expiresAt(), options: {} })
36
+ const grant = () => ({ grantId: 'grant', expiresAt: expiresAt() })
37
+ services = {
38
+ capability: vi.fn(),
39
+ captureLogin: vi.fn(() => binding),
40
+ managementContext: vi.fn(),
41
+ signInOptions: vi.fn(async () => ceremony()),
42
+ signIn: vi.fn(),
43
+ passwordProof: vi.fn(async () => grant()),
44
+ proofOptions: vi.fn(async () => ceremony()),
45
+ proof: vi.fn(async () => grant()),
46
+ enrollmentOptions: vi.fn(async () => ceremony()),
47
+ enrollment: vi.fn(),
48
+ list: vi.fn(),
49
+ rename: vi.fn(),
50
+ remove: vi.fn(),
51
+ } as unknown as PasskeyAdminServices
52
+ })
53
+ describe('browser passkey ceremonies', () => {
54
+ it('does not enter the cookie-changing submission until the prompt finishes', async () => {
55
+ let finish!: (value: unknown) => void
56
+ browser.authentication.mockImplementation(
57
+ () =>
58
+ new Promise((resolve) => {
59
+ finish = resolve
60
+ })
61
+ )
62
+ const pending = signInWithPasskey(services)
63
+ await vi.waitFor(() => expect(browser.authentication).toHaveBeenCalledOnce())
64
+ expect(services.signIn).not.toHaveBeenCalled()
65
+ finish({ id: 'verified' })
66
+ await pending
67
+ expect(services.signIn).toHaveBeenCalledWith(
68
+ { data: { ceremonyId: 'attempt', response: { id: 'verified' } } },
69
+ expect.objectContaining({ expectedSessionId: 'login-A' })
70
+ )
71
+ })
72
+ it.each(['AbortError', 'NotAllowedError'])(
73
+ 'cancellation %s does not submit or retry',
74
+ async (name) => {
75
+ browser.authentication.mockRejectedValue(Object.assign(new Error('cancelled'), { name }))
76
+ await expect(signInWithPasskey(services)).rejects.toThrow('cancelled')
77
+ expect(services.signInOptions).toHaveBeenCalledOnce()
78
+ expect(services.signIn).not.toHaveBeenCalled()
79
+ }
80
+ )
81
+ it('rejects a login switch during the authenticator prompt', async () => {
82
+ browser.authentication.mockImplementation(async () => {
83
+ vi.mocked(binding.assertCurrent).mockImplementation(() => {
84
+ throw new Error('session changed')
85
+ })
86
+ return { id: 'other-account' }
87
+ })
88
+ await expect(signInWithPasskey(services)).rejects.toThrow('session changed')
89
+ expect(services.signIn).not.toHaveBeenCalled()
90
+ })
91
+ it('abandons a completed prompt when the sign-in page has unmounted', async () => {
92
+ let active = true
93
+ browser.authentication.mockImplementation(async () => {
94
+ active = false
95
+ return {}
96
+ })
97
+ await expect(signInWithPasskey(services, () => active)).rejects.toThrow('page closed')
98
+ expect(services.signIn).not.toHaveBeenCalled()
99
+ })
100
+ it('does not open a device prompt for an expired challenge', async () => {
101
+ vi.mocked(services.signInOptions).mockResolvedValue({
102
+ ceremonyId: 'expired',
103
+ expiresAt: new Date(0).toISOString(),
104
+ options: {} as any,
105
+ })
106
+ await expect(signInWithPasskey(services)).rejects.toMatchObject({ code: 'ERR_INVALID_TOKEN' })
107
+ expect(browser.authentication).not.toHaveBeenCalled()
108
+ })
109
+ it('binds password proof and enrollment to the page login, without email input', async () => {
110
+ await enrollPasskey(services, binding, 'Laptop', 'password')
111
+ expect(services.passwordProof).toHaveBeenCalledWith({
112
+ data: { expectedSessionId: 'login-A', action: { purpose: 'enroll' }, password: 'password' },
113
+ })
114
+ expect(services.enrollmentOptions).toHaveBeenCalledWith({
115
+ data: { expectedSessionId: 'login-A', grantId: 'grant' },
116
+ })
117
+ expect(services.enrollment).toHaveBeenCalledWith({
118
+ data: {
119
+ expectedSessionId: 'login-A',
120
+ ceremonyId: 'attempt',
121
+ response: { id: 'attestation' },
122
+ name: 'Laptop',
123
+ },
124
+ })
125
+ })
126
+ it('does not enroll after a login change during registration', async () => {
127
+ browser.registration.mockImplementation(async () => {
128
+ vi.mocked(binding.assertCurrent).mockImplementation(() => {
129
+ throw new Error('session changed')
130
+ })
131
+ return { id: 'attestation' }
132
+ })
133
+ await expect(enrollPasskey(services, binding, 'Laptop', 'password')).rejects.toThrow(
134
+ 'session changed'
135
+ )
136
+ expect(services.enrollment).not.toHaveBeenCalled()
137
+ })
138
+ it('passes a scoped removal action and never accepts a wrong-account proof', async () => {
139
+ const action = {
140
+ purpose: 'remove' as const,
141
+ targetUserId: 'target',
142
+ credentialRecordId: 'record',
143
+ }
144
+ vi.mocked(services.proof).mockRejectedValue(
145
+ Object.assign(new Error('wrong account'), { code: 'ERR_INVALID_CREDENTIALS' })
146
+ )
147
+ await expect(provePasskeyAction(services, binding, action)).rejects.toThrow('wrong account')
148
+ expect(services.proofOptions).toHaveBeenCalledWith({
149
+ data: { expectedSessionId: 'login-A', action },
150
+ })
151
+ expect(services.remove).not.toHaveBeenCalled()
152
+ expect(services.proof).toHaveBeenCalledOnce()
153
+ })
154
+ it('does not retry ambiguous enrollment completion', async () => {
155
+ vi.mocked(services.enrollment).mockRejectedValue(new Error('connection lost'))
156
+ await expect(enrollPasskey(services, binding, 'Laptop', 'password')).rejects.toThrow(
157
+ 'connection lost'
158
+ )
159
+ expect(services.enrollment).toHaveBeenCalledOnce()
160
+ expect(services.enrollmentOptions).toHaveBeenCalledOnce()
161
+ })
162
+ it('keeps password proof available without WebAuthn support', async () => {
163
+ browser.supported.mockReturnValue(false)
164
+ await expect(
165
+ provePasskeyAction(
166
+ services,
167
+ binding,
168
+ { purpose: 'remove', targetUserId: 'target', credentialRecordId: 'key' },
169
+ 'password'
170
+ )
171
+ ).resolves.toMatchObject({ grantId: 'grant' })
172
+ await expect(signInWithPasskey(services)).rejects.toMatchObject({ code: 'PASSKEY_UNSUPPORTED' })
173
+ })
174
+ it.each([
175
+ [{ code: 'ERR_INVALID_TOKEN' }, 'expired'],
176
+ [{ code: 'ERR_SESSION_CHANGED' }, 'sessionChanged'],
177
+ [{ code: 'ERR_INVALID_CREDENTIALS' }, 'proof'],
178
+ [{ code: 'ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT' }, 'unsupported'],
179
+ [{ code: 'ERROR_AUTHENTICATOR_GENERAL_ERROR' }, 'device'],
180
+ [
181
+ { code: 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY', cause: { name: 'NotAllowedError' } },
182
+ 'cancelled',
183
+ ],
184
+ [{ status: 429 }, 'rateLimited'],
185
+ ])('maps bounded public error %j to usable copy', (error, key) => {
186
+ expect(passkeyErrorKey(error)).toBe(`passkeys.errors.${key}`)
187
+ })
188
+ })
@@ -0,0 +1,139 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { PasskeyManagementAction } from '@byline/auth'
10
+ import {
11
+ browserSupportsWebAuthn,
12
+ type PublicKeyCredentialCreationOptionsJSON,
13
+ type PublicKeyCredentialRequestOptionsJSON,
14
+ startAuthentication,
15
+ startRegistration,
16
+ } from '@simplewebauthn/browser'
17
+
18
+ import type {
19
+ PasskeyAdminServices,
20
+ PasskeyPageBinding,
21
+ } from '../../../services/admin-services-types.js'
22
+
23
+ function ready(binding: PasskeyPageBinding, expiresAt?: string) {
24
+ binding.assertCurrent()
25
+ if (expiresAt && Date.parse(expiresAt) <= Date.now())
26
+ throw Object.assign(new Error('Expired ceremony'), { code: 'ERR_INVALID_TOKEN' })
27
+ }
28
+ function supported() {
29
+ if (!browserSupportsWebAuthn())
30
+ throw Object.assign(new Error('WebAuthn unavailable'), { code: 'PASSKEY_UNSUPPORTED' })
31
+ }
32
+ export async function signInWithPasskey(
33
+ services: PasskeyAdminServices,
34
+ isActive: () => boolean = () => true
35
+ ) {
36
+ supported()
37
+ const captured = services.captureLogin(true)
38
+ const binding = {
39
+ ...captured,
40
+ assertCurrent: () => {
41
+ if (!isActive()) throw new Error('Sign-in page closed')
42
+ captured.assertCurrent()
43
+ },
44
+ }
45
+ const ceremony = await services.signInOptions({ data: {} })
46
+ ready(binding, ceremony.expiresAt)
47
+ const response = await startAuthentication({
48
+ optionsJSON: ceremony.options as PublicKeyCredentialRequestOptionsJSON,
49
+ })
50
+ ready(binding, ceremony.expiresAt)
51
+ return services.signIn({ data: { ceremonyId: ceremony.ceremonyId, response } }, binding)
52
+ }
53
+
54
+ export async function provePasskeyAction(
55
+ services: PasskeyAdminServices,
56
+ binding: PasskeyPageBinding,
57
+ action: PasskeyManagementAction,
58
+ password?: string
59
+ ) {
60
+ ready(binding)
61
+ const expectedSessionId = binding.expectedSessionId
62
+ if (!expectedSessionId)
63
+ throw Object.assign(new Error('Login changed'), { code: 'ERR_SESSION_CHANGED' })
64
+ if (password !== undefined) {
65
+ const grant = await services.passwordProof({ data: { expectedSessionId, action, password } })
66
+ ready(binding, grant.expiresAt)
67
+ return grant
68
+ }
69
+ supported()
70
+ const ceremony = await services.proofOptions({ data: { expectedSessionId, action } })
71
+ ready(binding, ceremony.expiresAt)
72
+ const response = await startAuthentication({
73
+ optionsJSON: ceremony.options as PublicKeyCredentialRequestOptionsJSON,
74
+ })
75
+ ready(binding, ceremony.expiresAt)
76
+ const grant = await services.proof({
77
+ data: { expectedSessionId, ceremonyId: ceremony.ceremonyId, response },
78
+ })
79
+ ready(binding, grant.expiresAt)
80
+ return grant
81
+ }
82
+
83
+ export async function enrollPasskey(
84
+ services: PasskeyAdminServices,
85
+ binding: PasskeyPageBinding,
86
+ name: string,
87
+ password?: string
88
+ ) {
89
+ supported()
90
+ const grant = await provePasskeyAction(services, binding, { purpose: 'enroll' }, password)
91
+ const expectedSessionId = binding.expectedSessionId!
92
+ const ceremony = await services.enrollmentOptions({
93
+ data: { expectedSessionId, grantId: grant.grantId },
94
+ })
95
+ ready(binding, ceremony.expiresAt)
96
+ const response = await startRegistration({
97
+ optionsJSON: ceremony.options as PublicKeyCredentialCreationOptionsJSON,
98
+ })
99
+ ready(binding, ceremony.expiresAt)
100
+ return services.enrollment({
101
+ data: { expectedSessionId, ceremonyId: ceremony.ceremonyId, response, name },
102
+ })
103
+ }
104
+
105
+ /** Render fixed translated messages, never provider payloads or authenticator identifiers. */
106
+ export function passkeyErrorKey(error: unknown): string {
107
+ const value = error as {
108
+ code?: string
109
+ name?: string
110
+ cause?: { name?: string }
111
+ status?: number
112
+ } | null
113
+ const name = value?.cause?.name ?? value?.name
114
+ if (
115
+ name === 'AbortError' ||
116
+ name === 'NotAllowedError' ||
117
+ value?.code === 'ERROR_CEREMONY_ABORTED'
118
+ )
119
+ return 'passkeys.errors.cancelled'
120
+ if (
121
+ name === 'ConstraintError' ||
122
+ name === 'NotSupportedError' ||
123
+ value?.code === 'PASSKEY_UNSUPPORTED' ||
124
+ value?.code?.startsWith('ERROR_AUTHENTICATOR_MISSING_') ||
125
+ value?.code === 'ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG'
126
+ )
127
+ return 'passkeys.errors.unsupported'
128
+ if (name === 'UnknownError' || value?.code === 'ERROR_AUTHENTICATOR_GENERAL_ERROR')
129
+ return 'passkeys.errors.device'
130
+ if (value?.code === 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED')
131
+ return 'passkeys.errors.duplicate'
132
+ if (value?.code === 'ERR_INVALID_TOKEN') return 'passkeys.errors.expired'
133
+ if (value?.code === 'ERR_SESSION_CHANGED') return 'passkeys.errors.sessionChanged'
134
+ if (value?.code === 'ERR_PASSKEY_LIMIT') return 'passkeys.errors.limit'
135
+ if (value?.code === 'ERR_FORBIDDEN') return 'passkeys.errors.forbidden'
136
+ if (value?.code === 'ERR_INVALID_CREDENTIALS') return 'passkeys.errors.proof'
137
+ if (value?.status === 429) return 'passkeys.errors.rateLimited'
138
+ return 'passkeys.errors.unavailable'
139
+ }
@@ -0,0 +1,100 @@
1
+ .manager {
2
+ padding: 1.25rem;
3
+ border: 1px solid var(--border-color, #8885);
4
+ border-radius: 0.5rem;
5
+ }
6
+ .list {
7
+ list-style: none;
8
+ padding: 0;
9
+ margin: 1rem 0;
10
+ }
11
+ .empty + .add {
12
+ margin-top: 0.25rem;
13
+ }
14
+ .row {
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: space-between;
18
+ flex-wrap: wrap;
19
+ gap: 0.75rem;
20
+ padding: 0.75rem 0;
21
+ border-bottom: 1px solid var(--border-color, #8885);
22
+ overflow-wrap: anywhere;
23
+ }
24
+ .row p {
25
+ font-size: 0.875rem;
26
+ margin: 0.25rem 0;
27
+ }
28
+ .actions {
29
+ display: flex;
30
+ flex-wrap: wrap;
31
+ gap: 0.5rem;
32
+ }
33
+
34
+ /*
35
+ * Add and rename open in a drawer, matching the other account and user edit
36
+ * panels; remove opens a modal, matching other destructive confirmations.
37
+ * Both share the action row below: below 40rem the actions stack full-width
38
+ * (touch-sized), with the primary action on top and Cancel last. From 40rem
39
+ * the small action buttons sit in one right-aligned row, which fits every
40
+ * bundled locale. The modal is capped at 540px, and the drawer is 540px from
41
+ * 48rem (40px wider than the account drawers) so the three proof actions fit
42
+ * one row in French.
43
+ */
44
+ .modal {
45
+ width: calc(100% - 2 * var(--spacing-8));
46
+ max-width: 540px;
47
+ margin-bottom: var(--spacing-8);
48
+ }
49
+ .drawer-body {
50
+ padding: var(--spacing-8);
51
+ }
52
+ .drawer-form {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: var(--spacing-16);
56
+ padding-top: var(--spacing-8);
57
+ }
58
+ .drawer-actions {
59
+ display: flex;
60
+ justify-content: flex-end;
61
+ gap: var(--spacing-12);
62
+ margin-top: var(--spacing-8);
63
+ }
64
+ .drawer-skip {
65
+ position: absolute;
66
+ width: 1px;
67
+ height: 1px;
68
+ padding: 0;
69
+ margin: -1px;
70
+ overflow: hidden;
71
+ clip: rect(0, 0, 0, 0);
72
+ white-space: nowrap;
73
+ border: 0;
74
+ }
75
+ .modal-actions {
76
+ flex-direction: column-reverse;
77
+ align-items: stretch;
78
+ }
79
+ @media (max-width: 39.99rem) {
80
+ .modal-actions > * {
81
+ min-height: 2.5rem;
82
+ }
83
+ }
84
+ @media (min-width: 40rem) {
85
+ .modal {
86
+ width: 100%;
87
+ margin-bottom: 0;
88
+ }
89
+ .modal-actions {
90
+ flex-direction: row;
91
+ flex-wrap: wrap;
92
+ align-items: center;
93
+ gap: var(--spacing-8);
94
+ }
95
+ }
96
+ @media (min-width: 48rem) {
97
+ .drawer {
98
+ width: 540px;
99
+ }
100
+ }