@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.
- package/dist/modules/admin-account/components/change-password.js +1 -1
- package/dist/modules/admin-account/components/container.js +18 -3
- package/dist/modules/admin-account/components/container.module.js +2 -0
- package/dist/modules/admin-account/components/container_module.css +7 -0
- package/dist/modules/admin-account/components/preferences.js +1 -1
- package/dist/modules/admin-account/components/update.js +1 -1
- package/dist/modules/admin-roles/components/create.js +1 -1
- package/dist/modules/admin-roles/components/permissions.js +1 -1
- package/dist/modules/admin-roles/components/update.js +1 -1
- package/dist/modules/admin-users/abilities.d.ts +1 -0
- package/dist/modules/admin-users/abilities.js +8 -1
- package/dist/modules/admin-users/components/create.js +1 -1
- package/dist/modules/admin-users/components/roles.js +1 -1
- package/dist/modules/admin-users/components/set-password.js +1 -1
- package/dist/modules/admin-users/components/update.js +1 -1
- package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
- package/dist/modules/auth/auth-maintenance-repository.js +1 -0
- package/dist/modules/auth/auth-maintenance.d.ts +11 -0
- package/dist/modules/auth/auth-maintenance.js +39 -0
- package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
- package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
- package/dist/modules/auth/components/passkey-browser.js +118 -0
- package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
- package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
- package/dist/modules/auth/components/passkey-manager.js +406 -0
- package/dist/modules/auth/components/passkey-manager.module.js +22 -0
- package/dist/modules/auth/components/passkey-manager_module.css +104 -0
- package/dist/modules/auth/components/sign-in-form.js +62 -3
- package/dist/modules/auth/components/sign-in-form.module.js +5 -1
- package/dist/modules/auth/components/sign-in-form_module.css +40 -0
- package/dist/modules/auth/index.d.ts +5 -0
- package/dist/modules/auth/index.js +3 -0
- package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
- package/dist/modules/auth/jwt-session-provider.js +135 -94
- package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
- package/dist/modules/auth/native-passkey-provider.js +618 -0
- package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
- package/dist/modules/auth/passkey-rate-limiter.js +60 -0
- package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
- package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
- package/dist/modules/auth/passkey-storage-validation.js +59 -0
- package/dist/modules/auth/passkeys-repository.d.ts +96 -0
- package/dist/modules/auth/passkeys-repository.js +1 -0
- package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
- package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
- package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
- package/dist/services/admin-services-context.d.ts +1 -1
- package/dist/services/admin-services-types.d.ts +60 -0
- package/dist/store.d.ts +4 -0
- package/package.json +16 -9
- package/src/modules/admin-account/components/change-password.tsx +1 -1
- package/src/modules/admin-account/components/container.module.css +9 -0
- package/src/modules/admin-account/components/container.tsx +12 -3
- package/src/modules/admin-account/components/preferences.tsx +1 -1
- package/src/modules/admin-account/components/update.tsx +1 -1
- package/src/modules/admin-roles/components/create.tsx +1 -1
- package/src/modules/admin-roles/components/permissions.tsx +1 -1
- package/src/modules/admin-roles/components/update.tsx +1 -1
- package/src/modules/admin-users/abilities.ts +7 -0
- package/src/modules/admin-users/components/create.tsx +1 -1
- package/src/modules/admin-users/components/roles.tsx +1 -1
- package/src/modules/admin-users/components/set-password.tsx +1 -1
- package/src/modules/admin-users/components/update.tsx +1 -1
- package/src/modules/auth/auth-maintenance-repository.ts +26 -0
- package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
- package/src/modules/auth/auth-maintenance.ts +62 -0
- package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
- package/src/modules/auth/components/passkey-browser.ts +139 -0
- package/src/modules/auth/components/passkey-manager.module.css +100 -0
- package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
- package/src/modules/auth/components/passkey-manager.tsx +386 -0
- package/src/modules/auth/components/sign-in-form.module.css +37 -0
- package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
- package/src/modules/auth/components/sign-in-form.tsx +65 -4
- package/src/modules/auth/index.ts +15 -0
- package/src/modules/auth/jwt-session-provider.ts +116 -44
- package/src/modules/auth/native-passkey-provider.ts +710 -0
- package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
- package/src/modules/auth/passkey-rate-limiter.ts +76 -0
- package/src/modules/auth/passkey-storage-validation.ts +108 -0
- package/src/modules/auth/passkeys-repository.ts +97 -0
- package/src/modules/auth/refresh-tokens-repository.ts +1 -1
- package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
- package/src/services/admin-services-context.tsx +2 -0
- package/src/services/admin-services-types.ts +60 -0
- 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
|
+
}
|