@byline/admin 6.7.2 → 6.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fields/blocks/blocks-field.js +33 -3
- package/dist/fields/blocks/blocks-field.module.js +1 -0
- package/dist/fields/blocks/blocks-field_module.css +19 -0
- package/dist/forms/form-page-chrome.d.ts +3 -2
- package/dist/forms/form-page-chrome.js +13 -1
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +23 -17
- package/dist/forms/unavailable-blocks.d.ts +15 -0
- package/dist/forms/unavailable-blocks.js +38 -0
- 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/fields/blocks/blocks-field.module.css +23 -0
- package/src/fields/blocks/blocks-field.tsx +26 -2
- package/src/forms/form-page-chrome.tsx +19 -1
- package/src/forms/form-renderer-capabilities.test.tsx +6 -6
- package/src/forms/form-renderer.tsx +24 -15
- package/src/forms/unavailable-blocks.test.tsx +152 -0
- package/src/forms/unavailable-blocks.tsx +58 -0
- 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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
12
|
|
|
13
|
-
import type { AdminResourceConfig, Field, WorkflowStatus } from '@byline/core'
|
|
13
|
+
import type { AdminResourceConfig, Field, UnavailableBlock, WorkflowStatus } from '@byline/core'
|
|
14
14
|
import { getAdminConfig } from '@byline/core'
|
|
15
15
|
import { useTranslation } from '@byline/i18n/react'
|
|
16
16
|
import cx from 'clsx'
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
useScheduledPublication,
|
|
34
34
|
} from './scheduled-publication-control'
|
|
35
35
|
import { computeStatusTransitions } from './status-transitions'
|
|
36
|
+
import { UnavailableBlocksProvider } from './unavailable-blocks'
|
|
36
37
|
import { useDocumentReloadRecovery } from './use-document-reload-recovery'
|
|
37
38
|
import { type SystemFieldsSubmitPayload, useFormSubmission } from './use-form-submission'
|
|
38
39
|
import type { DocumentActionsLocaleOption } from './document-actions'
|
|
@@ -168,6 +169,7 @@ export interface FormRendererProps {
|
|
|
168
169
|
* them with the new shape.
|
|
169
170
|
*/
|
|
170
171
|
restoreWarnings?: string[]
|
|
172
|
+
unavailableBlocks?: UnavailableBlock[]
|
|
171
173
|
/**
|
|
172
174
|
* Default content locale used when no `initialLocale` is supplied and as the
|
|
173
175
|
* fallback inside `PathWidget`. Hosts typically pass their app-wide
|
|
@@ -225,6 +227,7 @@ const FormContent = ({
|
|
|
225
227
|
defaultLocale = 'en',
|
|
226
228
|
useNavigationGuard: useNavigationGuardProp,
|
|
227
229
|
restoreWarnings,
|
|
230
|
+
unavailableBlocks,
|
|
228
231
|
_activeTabBySet,
|
|
229
232
|
_onTabChange,
|
|
230
233
|
}: FormRendererProps & {
|
|
@@ -455,6 +458,7 @@ const FormContent = ({
|
|
|
455
458
|
onDiscardRequested={requestDiscard}
|
|
456
459
|
scheduling={scheduling}
|
|
457
460
|
restoreWarnings={restoreWarnings}
|
|
461
|
+
unavailableBlocks={unavailableBlocks}
|
|
458
462
|
/>
|
|
459
463
|
<FormLayout
|
|
460
464
|
fields={fields}
|
|
@@ -516,23 +520,28 @@ export const FormRenderer = (props: FormRendererProps) => {
|
|
|
516
520
|
const savedTabsRef = useRef<Record<string, string>>({})
|
|
517
521
|
|
|
518
522
|
return (
|
|
519
|
-
<
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
|
|
523
|
-
collectionPath={collectionPath ?? null}
|
|
523
|
+
<UnavailableBlocksProvider
|
|
524
|
+
issues={props.unavailableBlocks}
|
|
525
|
+
fields={initialData?.fields ?? initialData ?? {}}
|
|
524
526
|
>
|
|
525
|
-
|
|
527
|
+
<FormProvider
|
|
528
|
+
key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
|
|
529
|
+
initialData={initialData}
|
|
530
|
+
documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
|
|
531
|
+
collectionPath={collectionPath ?? null}
|
|
532
|
+
>
|
|
533
|
+
{/* Forwarded wholesale. An enumerated prop list here silently dropped
|
|
526
534
|
every prop added to FormRendererProps but not copied down — which is
|
|
527
535
|
how the scheduled-publication handlers reached FormContent as
|
|
528
536
|
`undefined` and the control never rendered. */}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
+
<FormContent
|
|
538
|
+
{...props}
|
|
539
|
+
_activeTabBySet={savedTabsRef.current}
|
|
540
|
+
_onTabChange={(tabSetName, tabName) => {
|
|
541
|
+
savedTabsRef.current = { ...savedTabsRef.current, [tabSetName]: tabName }
|
|
542
|
+
}}
|
|
543
|
+
/>
|
|
544
|
+
</FormProvider>
|
|
545
|
+
</UnavailableBlocksProvider>
|
|
537
546
|
)
|
|
538
547
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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 { act } from 'react'
|
|
10
|
+
|
|
11
|
+
import { type BlocksField as BlocksFieldType, defineAdminConfig } from '@byline/core'
|
|
12
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
13
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
14
|
+
import { createRoot } from 'react-dom/client'
|
|
15
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
16
|
+
|
|
17
|
+
import { BlocksField } from '../fields/blocks/blocks-field'
|
|
18
|
+
import { FormProvider, useFormContext } from './form-context'
|
|
19
|
+
import { FormConcurrencyNotices } from './form-page-chrome'
|
|
20
|
+
import { UnavailableBlocksProvider, useUnavailableBlocks } from './unavailable-blocks'
|
|
21
|
+
|
|
22
|
+
vi.mock('../fields/group/group-field', () => ({
|
|
23
|
+
GroupField: ({ path }: { path: string }) => {
|
|
24
|
+
const form = useFormContext()
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
data-live={path}
|
|
29
|
+
onClick={() =>
|
|
30
|
+
form.appendPatch({ kind: 'field.set', path: `${path}.text`, value: 'edited' })
|
|
31
|
+
}
|
|
32
|
+
>
|
|
33
|
+
Edit live
|
|
34
|
+
</button>
|
|
35
|
+
)
|
|
36
|
+
},
|
|
37
|
+
}))
|
|
38
|
+
vi.mock('../fields/sortable-item', () => ({
|
|
39
|
+
SortableItem: ({ children }: any) => <div>{children}</div>,
|
|
40
|
+
StaticItem: ({ children }: any) => <div>{children}</div>,
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const field: BlocksFieldType = {
|
|
44
|
+
name: 'content',
|
|
45
|
+
type: 'blocks',
|
|
46
|
+
blocks: [{ blockType: 'live', fields: [{ name: 'text', type: 'text' }] }],
|
|
47
|
+
}
|
|
48
|
+
const fields = {
|
|
49
|
+
content: [
|
|
50
|
+
{ _id: 'a', _type: 'live', text: 'A' },
|
|
51
|
+
{ _id: 'b', _type: 'live', text: 'B' },
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
const issues = [0, 2, 4].map((storedIndex) => ({
|
|
55
|
+
fieldPath: 'content',
|
|
56
|
+
storedIndex,
|
|
57
|
+
blockType: `removed-${storedIndex}`,
|
|
58
|
+
}))
|
|
59
|
+
let container: HTMLDivElement
|
|
60
|
+
let root: ReturnType<typeof createRoot>
|
|
61
|
+
let form: ReturnType<typeof useFormContext>
|
|
62
|
+
function Probe() {
|
|
63
|
+
form = useFormContext()
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
function render(reports = issues) {
|
|
67
|
+
act(() =>
|
|
68
|
+
root.render(
|
|
69
|
+
<I18nProvider
|
|
70
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
71
|
+
activeLocale="en"
|
|
72
|
+
defaultLocale="en"
|
|
73
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
74
|
+
>
|
|
75
|
+
<UnavailableBlocksProvider issues={reports} fields={fields}>
|
|
76
|
+
<FormProvider initialData={{ fields }}>
|
|
77
|
+
<Probe />
|
|
78
|
+
<FormConcurrencyNotices
|
|
79
|
+
disabled={false}
|
|
80
|
+
warningRef={{ current: null }}
|
|
81
|
+
discarding={false}
|
|
82
|
+
reloadFailed={false}
|
|
83
|
+
onDiscardRequested={() => {}}
|
|
84
|
+
scheduling={{ modal: null, state: { isExceptional: false }, timeZone: 'UTC' } as any}
|
|
85
|
+
unavailableBlocks={reports}
|
|
86
|
+
/>
|
|
87
|
+
<BlocksField field={field} path="content" defaultValue={fields.content} />
|
|
88
|
+
</FormProvider>
|
|
89
|
+
</UnavailableBlocksProvider>
|
|
90
|
+
</I18nProvider>
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
96
|
+
defineAdminConfig({
|
|
97
|
+
collections: [],
|
|
98
|
+
i18n: {
|
|
99
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
100
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
container = document.createElement('div')
|
|
104
|
+
document.body.append(container)
|
|
105
|
+
root = createRoot(container)
|
|
106
|
+
})
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
act(() => root.unmount())
|
|
109
|
+
container.remove()
|
|
110
|
+
vi.unstubAllGlobals()
|
|
111
|
+
})
|
|
112
|
+
it('interleaves display-only cards, keeps live patch addressing, and clears notices after reload', async () => {
|
|
113
|
+
render()
|
|
114
|
+
expect(container.textContent).toContain('This document contains 3 blocks')
|
|
115
|
+
const entries = [...container.querySelectorAll('[role="note"], [data-live]')]
|
|
116
|
+
expect(
|
|
117
|
+
entries.map(
|
|
118
|
+
(entry) => entry.getAttribute('data-live') ?? entry.querySelector('strong')?.textContent
|
|
119
|
+
)
|
|
120
|
+
).toEqual([
|
|
121
|
+
'Unavailable block: removed-0',
|
|
122
|
+
'content[id=a]',
|
|
123
|
+
'Unavailable block: removed-2',
|
|
124
|
+
'content[id=b]',
|
|
125
|
+
'Unavailable block: removed-4',
|
|
126
|
+
])
|
|
127
|
+
expect(form.getFieldValues()).toEqual(fields)
|
|
128
|
+
await act(async () => (container.querySelectorAll('[data-live]')[1] as HTMLButtonElement).click())
|
|
129
|
+
expect(form.getPatches()).toEqual([
|
|
130
|
+
{ kind: 'field.set', path: 'content[id=b].text', value: 'edited' },
|
|
131
|
+
])
|
|
132
|
+
render([])
|
|
133
|
+
expect(container.querySelector('[role="note"]')).toBeNull()
|
|
134
|
+
expect(container.textContent).not.toContain('This document contains')
|
|
135
|
+
})
|
|
136
|
+
it('binds nested paths to surviving parent identities', () => {
|
|
137
|
+
function NestedProbe() {
|
|
138
|
+
const issues = useUnavailableBlocks('outer[id=parent].body[id=second].content')
|
|
139
|
+
return <span>{issues.map((issue) => issue.blockType).join(',')}</span>
|
|
140
|
+
}
|
|
141
|
+
act(() =>
|
|
142
|
+
root.render(
|
|
143
|
+
<UnavailableBlocksProvider
|
|
144
|
+
issues={[{ fieldPath: 'outer[0].body[1].content', storedIndex: 0, blockType: 'removed' }]}
|
|
145
|
+
fields={{ outer: [{ _id: 'parent', body: [{ _id: 'first' }, { _id: 'second' }] }] }}
|
|
146
|
+
>
|
|
147
|
+
<NestedProbe />
|
|
148
|
+
</UnavailableBlocksProvider>
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
expect(container.textContent).toBe('removed')
|
|
152
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { ReactNode } from 'react'
|
|
10
|
+
import { createContext, useContext, useMemo } from 'react'
|
|
11
|
+
|
|
12
|
+
import { parseInstancePath, type UnavailableBlock } from '@byline/core'
|
|
13
|
+
|
|
14
|
+
import { repeatingItemPath } from './repeating-items'
|
|
15
|
+
|
|
16
|
+
const UnavailableBlocksContext = createContext<readonly UnavailableBlock[]>([])
|
|
17
|
+
|
|
18
|
+
/** Bind numeric read paths to initial item identities, so cards follow moved parents. */
|
|
19
|
+
function bindUnavailableBlocks(
|
|
20
|
+
issues: readonly UnavailableBlock[],
|
|
21
|
+
fields: Record<string, any>
|
|
22
|
+
): UnavailableBlock[] {
|
|
23
|
+
return issues.map((issue) => {
|
|
24
|
+
const parsed = parseInstancePath(issue.fieldPath)
|
|
25
|
+
if (!parsed.ok) return issue
|
|
26
|
+
let value: any = fields
|
|
27
|
+
let path = ''
|
|
28
|
+
for (const segment of parsed.segments) {
|
|
29
|
+
if (segment.kind === 'field') {
|
|
30
|
+
path = path ? `${path}.${segment.name}` : segment.name
|
|
31
|
+
value = value?.[segment.name]
|
|
32
|
+
} else if (segment.kind === 'index') {
|
|
33
|
+
value = value?.[segment.index]
|
|
34
|
+
path = repeatingItemPath(path, value, segment.index)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ...issue, fieldPath: path }
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function UnavailableBlocksProvider({
|
|
42
|
+
issues,
|
|
43
|
+
fields,
|
|
44
|
+
children,
|
|
45
|
+
}: {
|
|
46
|
+
issues?: readonly UnavailableBlock[]
|
|
47
|
+
fields: Record<string, any>
|
|
48
|
+
children: ReactNode
|
|
49
|
+
}) {
|
|
50
|
+
const bound = useMemo(() => bindUnavailableBlocks(issues ?? [], fields), [issues, fields])
|
|
51
|
+
return (
|
|
52
|
+
<UnavailableBlocksContext.Provider value={bound}>{children}</UnavailableBlocksContext.Provider>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useUnavailableBlocks(path: string) {
|
|
57
|
+
return useContext(UnavailableBlocksContext).filter((issue) => issue.fieldPath === path)
|
|
58
|
+
}
|
|
@@ -215,7 +215,7 @@ export function ChangeAccountPassword({ account, onClose, onSuccess }: ChangePas
|
|
|
215
215
|
<div className={cx('byline-account-change-password-actions', styles.actions)}>
|
|
216
216
|
<Button
|
|
217
217
|
type="button"
|
|
218
|
-
intent="
|
|
218
|
+
intent="noeffect"
|
|
219
219
|
size="sm"
|
|
220
220
|
onClick={onClose}
|
|
221
221
|
className={cx('byline-account-change-password-action', styles.action)}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* .byline-account-status-on
|
|
14
14
|
* .byline-account-status-off
|
|
15
15
|
* .byline-account-status-help — italic helper line on Account Status card
|
|
16
|
+
* .byline-account-preferences-theme — colour theme block inside Preferences
|
|
16
17
|
* .byline-account-drawer — drawer responsive width override
|
|
17
18
|
* .byline-account-drawer-body — drawer content container
|
|
18
19
|
* .byline-account-drawer-scroll — drawer scrollable inner viewport
|
|
@@ -92,6 +93,14 @@
|
|
|
92
93
|
font-size: var(--font-size-xs);
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
.preferences-theme,
|
|
97
|
+
:global(.byline-account-preferences-theme) {
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
gap: var(--spacing-8);
|
|
101
|
+
margin-top: var(--spacing-20);
|
|
102
|
+
}
|
|
103
|
+
|
|
95
104
|
/*
|
|
96
105
|
* Italic helper for "Not set" placeholders. Replaces the Tailwind
|
|
97
106
|
* `italic` utility we used pre-lift; with the component now in a
|
|
@@ -32,6 +32,7 @@ import { useTranslation } from '@byline/i18n/react'
|
|
|
32
32
|
import { CloseIcon, Drawer, EditIcon, IconButton } from '@byline/ui/react'
|
|
33
33
|
import cx from 'clsx'
|
|
34
34
|
|
|
35
|
+
import { PasskeyManager } from '../../auth/components/passkey-manager.js'
|
|
35
36
|
import { ChangeAccountPassword } from './change-password.js'
|
|
36
37
|
import styles from './container.module.css'
|
|
37
38
|
import { Preferences } from './preferences.js'
|
|
@@ -180,6 +181,12 @@ export function AccountSelfContainer({ account }: AccountSelfContainerProps) {
|
|
|
180
181
|
<p className={cx('muted', 'byline-account-status-help', styles['status-help'])}>
|
|
181
182
|
{t('account.preferences.help')}
|
|
182
183
|
</p>
|
|
184
|
+
<div className={cx('byline-account-preferences-theme', styles['preferences-theme'])}>
|
|
185
|
+
<p className={cx('byline-account-line', styles.line)}>
|
|
186
|
+
<span className="muted">{t('account.appearance.colorTheme')}</span>
|
|
187
|
+
</p>
|
|
188
|
+
<ThemeSwitch />
|
|
189
|
+
</div>
|
|
183
190
|
</ContainerSection>
|
|
184
191
|
</div>
|
|
185
192
|
|
|
@@ -192,9 +199,11 @@ export function AccountSelfContainer({ account }: AccountSelfContainerProps) {
|
|
|
192
199
|
<p className={cx('byline-account-line', styles.line)}>{t('account.password.intro')}</p>
|
|
193
200
|
</ContainerSection>
|
|
194
201
|
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
<PasskeyManager
|
|
203
|
+
targetUserId={currentAccount.id}
|
|
204
|
+
targetIsSuperAdmin={currentAccount.is_super_admin}
|
|
205
|
+
section={ContainerSection}
|
|
206
|
+
/>
|
|
198
207
|
|
|
199
208
|
<ContainerSection title={t('account.sections.status')}>
|
|
200
209
|
<p className={cx('byline-account-line', styles.line)}>
|
|
@@ -172,7 +172,7 @@ export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
|
|
|
172
172
|
<div className={cx('byline-account-preferences-actions', styles.actions)}>
|
|
173
173
|
<Button
|
|
174
174
|
type="button"
|
|
175
|
-
intent="
|
|
175
|
+
intent="noeffect"
|
|
176
176
|
size="sm"
|
|
177
177
|
onClick={onClose}
|
|
178
178
|
className={cx('byline-account-preferences-action', styles.action)}
|
|
@@ -240,7 +240,7 @@ export function UpdateAccount({ account, onClose, onSuccess }: UpdateAccountProp
|
|
|
240
240
|
<div className={cx('byline-account-update-actions', styles.actions)}>
|
|
241
241
|
<Button
|
|
242
242
|
type="button"
|
|
243
|
-
intent="
|
|
243
|
+
intent="noeffect"
|
|
244
244
|
size="sm"
|
|
245
245
|
onClick={onClose}
|
|
246
246
|
className={cx('byline-account-update-action', styles.action)}
|
|
@@ -192,7 +192,7 @@ export function CreateAdminRole({ onClose, onSuccess }: CreateAdminRoleProps) {
|
|
|
192
192
|
<div className={cx('byline-role-create-actions', styles.actions)}>
|
|
193
193
|
<Button
|
|
194
194
|
type="button"
|
|
195
|
-
intent="
|
|
195
|
+
intent="noeffect"
|
|
196
196
|
size="sm"
|
|
197
197
|
onClick={onClose}
|
|
198
198
|
className={cx('byline-role-create-action', styles.action)}
|
|
@@ -188,7 +188,7 @@ export function UpdateRole({ role, onClose, onSuccess }: UpdateRoleProps) {
|
|
|
188
188
|
<div className={cx('byline-role-update-actions', styles.actions)}>
|
|
189
189
|
<Button
|
|
190
190
|
type="button"
|
|
191
|
-
intent="
|
|
191
|
+
intent="noeffect"
|
|
192
192
|
size="sm"
|
|
193
193
|
onClick={onClose}
|
|
194
194
|
className={cx('byline-role-update-action', styles.action)}
|
|
@@ -32,6 +32,7 @@ export const ADMIN_USERS_ABILITIES = {
|
|
|
32
32
|
update: 'admin.users.update',
|
|
33
33
|
delete: 'admin.users.delete',
|
|
34
34
|
changePassword: 'admin.users.changePassword',
|
|
35
|
+
managePasskeys: 'admin.users.managePasskeys',
|
|
35
36
|
} as const
|
|
36
37
|
|
|
37
38
|
export type AdminUsersAbilityKey =
|
|
@@ -43,6 +44,12 @@ export type AdminUsersAbilityKey =
|
|
|
43
44
|
* the webapp wires into `initBylineCore()`.
|
|
44
45
|
*/
|
|
45
46
|
export function registerAdminUsersAbilities(registry: AbilityRegistry): void {
|
|
47
|
+
registry.register({
|
|
48
|
+
key: ADMIN_USERS_ABILITIES.managePasskeys,
|
|
49
|
+
label: 'Manage admin user passkeys',
|
|
50
|
+
group: 'admin.users',
|
|
51
|
+
source: 'admin',
|
|
52
|
+
})
|
|
46
53
|
registry.register({
|
|
47
54
|
key: ADMIN_USERS_ABILITIES.read,
|
|
48
55
|
label: 'Read admin users',
|
|
@@ -299,7 +299,7 @@ export function CreateAdminUser({ onClose, onSuccess }: CreateAdminUserProps) {
|
|
|
299
299
|
<div className={cx('byline-user-create-actions', styles.actions)}>
|
|
300
300
|
<Button
|
|
301
301
|
type="button"
|
|
302
|
-
intent="
|
|
302
|
+
intent="noeffect"
|
|
303
303
|
size="sm"
|
|
304
304
|
onClick={onClose}
|
|
305
305
|
className={cx('byline-user-create-action', styles.action)}
|
|
@@ -143,7 +143,7 @@ export function UserRoles({ user, allRoles, initialRoleIds, onClose, onSaved }:
|
|
|
143
143
|
<div className={cx('byline-user-roles-actions', styles.actions)}>
|
|
144
144
|
<Button
|
|
145
145
|
type="button"
|
|
146
|
-
intent="
|
|
146
|
+
intent="noeffect"
|
|
147
147
|
size="xs"
|
|
148
148
|
onClick={onClose}
|
|
149
149
|
disabled={saving}
|
|
@@ -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="
|
|
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="
|
|
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
|
+
})
|