@byline/admin 6.7.2 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/fields/blocks/blocks-field.js +33 -3
  2. package/dist/fields/blocks/blocks-field.module.js +1 -0
  3. package/dist/fields/blocks/blocks-field_module.css +19 -0
  4. package/dist/forms/form-page-chrome.d.ts +3 -2
  5. package/dist/forms/form-page-chrome.js +13 -1
  6. package/dist/forms/form-renderer.d.ts +2 -1
  7. package/dist/forms/form-renderer.js +23 -17
  8. package/dist/forms/unavailable-blocks.d.ts +15 -0
  9. package/dist/forms/unavailable-blocks.js +38 -0
  10. package/dist/modules/admin-account/components/change-password.js +1 -1
  11. package/dist/modules/admin-account/components/container.js +18 -3
  12. package/dist/modules/admin-account/components/container.module.js +2 -0
  13. package/dist/modules/admin-account/components/container_module.css +7 -0
  14. package/dist/modules/admin-account/components/preferences.js +1 -1
  15. package/dist/modules/admin-account/components/update.js +1 -1
  16. package/dist/modules/admin-roles/components/create.js +1 -1
  17. package/dist/modules/admin-roles/components/permissions.js +1 -1
  18. package/dist/modules/admin-roles/components/update.js +1 -1
  19. package/dist/modules/admin-users/abilities.d.ts +1 -0
  20. package/dist/modules/admin-users/abilities.js +8 -1
  21. package/dist/modules/admin-users/components/create.js +1 -1
  22. package/dist/modules/admin-users/components/roles.js +1 -1
  23. package/dist/modules/admin-users/components/set-password.js +1 -1
  24. package/dist/modules/admin-users/components/update.js +1 -1
  25. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  26. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  27. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  28. package/dist/modules/auth/auth-maintenance.js +39 -0
  29. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  30. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  31. package/dist/modules/auth/components/passkey-browser.js +118 -0
  32. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  33. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  34. package/dist/modules/auth/components/passkey-manager.js +406 -0
  35. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  36. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  37. package/dist/modules/auth/components/sign-in-form.js +62 -3
  38. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  39. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  40. package/dist/modules/auth/index.d.ts +5 -0
  41. package/dist/modules/auth/index.js +3 -0
  42. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  43. package/dist/modules/auth/jwt-session-provider.js +135 -94
  44. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  45. package/dist/modules/auth/native-passkey-provider.js +618 -0
  46. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  47. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  48. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  49. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  50. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  51. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  52. package/dist/modules/auth/passkeys-repository.js +1 -0
  53. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  54. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  55. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  56. package/dist/services/admin-services-context.d.ts +1 -1
  57. package/dist/services/admin-services-types.d.ts +60 -0
  58. package/dist/store.d.ts +4 -0
  59. package/package.json +16 -9
  60. package/src/fields/blocks/blocks-field.module.css +23 -0
  61. package/src/fields/blocks/blocks-field.tsx +26 -2
  62. package/src/forms/form-page-chrome.tsx +19 -1
  63. package/src/forms/form-renderer-capabilities.test.tsx +6 -6
  64. package/src/forms/form-renderer.tsx +24 -15
  65. package/src/forms/unavailable-blocks.test.tsx +152 -0
  66. package/src/forms/unavailable-blocks.tsx +58 -0
  67. package/src/modules/admin-account/components/change-password.tsx +1 -1
  68. package/src/modules/admin-account/components/container.module.css +9 -0
  69. package/src/modules/admin-account/components/container.tsx +12 -3
  70. package/src/modules/admin-account/components/preferences.tsx +1 -1
  71. package/src/modules/admin-account/components/update.tsx +1 -1
  72. package/src/modules/admin-roles/components/create.tsx +1 -1
  73. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  74. package/src/modules/admin-roles/components/update.tsx +1 -1
  75. package/src/modules/admin-users/abilities.ts +7 -0
  76. package/src/modules/admin-users/components/create.tsx +1 -1
  77. package/src/modules/admin-users/components/roles.tsx +1 -1
  78. package/src/modules/admin-users/components/set-password.tsx +1 -1
  79. package/src/modules/admin-users/components/update.tsx +1 -1
  80. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  81. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  82. package/src/modules/auth/auth-maintenance.ts +62 -0
  83. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  84. package/src/modules/auth/components/passkey-browser.ts +139 -0
  85. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  86. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  87. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  88. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  89. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  90. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  91. package/src/modules/auth/index.ts +15 -0
  92. package/src/modules/auth/jwt-session-provider.ts +116 -44
  93. package/src/modules/auth/native-passkey-provider.ts +710 -0
  94. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  95. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  96. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  97. package/src/modules/auth/passkeys-repository.ts +97 -0
  98. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  99. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  100. package/src/services/admin-services-context.tsx +2 -0
  101. package/src/services/admin-services-types.ts +60 -0
  102. package/src/store.ts +4 -0
@@ -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
- <FormProvider
520
- key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
521
- initialData={initialData}
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
- {/* Forwarded wholesale. An enumerated prop list here silently dropped
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
- <FormContent
530
- {...props}
531
- _activeTabBySet={savedTabsRef.current}
532
- _onTabChange={(tabSetName, tabName) => {
533
- savedTabsRef.current = { ...savedTabsRef.current, [tabSetName]: tabName }
534
- }}
535
- />
536
- </FormProvider>
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="secondary"
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
- <ContainerSection title={t('account.sections.appearance')}>
196
- <ThemeSwitch />
197
- </ContainerSection>
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="secondary"
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="secondary"
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="secondary"
195
+ intent="noeffect"
196
196
  size="sm"
197
197
  onClick={onClose}
198
198
  className={cx('byline-role-create-action', styles.action)}
@@ -288,7 +288,7 @@ export function RolePermissions({
288
288
  <div className={cx('byline-role-permissions-actions', styles.actions)}>
289
289
  <Button
290
290
  type="button"
291
- intent="secondary"
291
+ intent="noeffect"
292
292
  size="xs"
293
293
  onClick={handleCancel}
294
294
  disabled={saving}
@@ -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="secondary"
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="secondary"
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="secondary"
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="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
+ })