@byline/admin 4.19.0 → 5.1.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 (57) hide show
  1. package/dist/fields/field-services-context.d.ts +1 -1
  2. package/dist/fields/field-services-types.d.ts +5 -2
  3. package/dist/forms/available-locales-widget.d.ts +3 -2
  4. package/dist/forms/available-locales-widget.js +4 -2
  5. package/dist/forms/document-actions.d.ts +3 -2
  6. package/dist/forms/document-actions.js +32 -14
  7. package/dist/forms/form-renderer.d.ts +12 -3
  8. package/dist/forms/form-renderer.js +98 -8
  9. package/dist/forms/form-renderer.module.js +1 -0
  10. package/dist/forms/form-renderer_module.css +12 -0
  11. package/dist/forms/form-status-display.d.ts +3 -2
  12. package/dist/forms/form-status-display.js +5 -2
  13. package/dist/forms/path-widget.d.ts +2 -1
  14. package/dist/forms/path-widget.js +7 -2
  15. package/dist/forms/scheduled-publication-control.d.ts +2 -1
  16. package/dist/forms/scheduled-publication-control.js +12 -8
  17. package/dist/forms/tree-placement-widget.d.ts +5 -1
  18. package/dist/forms/tree-placement-widget.js +14 -7
  19. package/dist/forms/upload-executor.d.ts +2 -2
  20. package/dist/modules/admin-account/components/change-password.js +18 -0
  21. package/dist/modules/admin-account/service.d.ts +2 -6
  22. package/dist/modules/admin-account/service.js +2 -1
  23. package/dist/modules/admin-users/repository.d.ts +10 -1
  24. package/dist/modules/auth/index.d.ts +3 -1
  25. package/dist/modules/auth/index.js +1 -0
  26. package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
  27. package/dist/modules/auth/jwt-session-provider.js +222 -70
  28. package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
  29. package/dist/modules/auth/login-sessions-repository.js +1 -0
  30. package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
  31. package/dist/modules/auth/resolve-actor.d.ts +3 -0
  32. package/dist/modules/auth/resolve-actor.js +5 -2
  33. package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
  34. package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
  35. package/dist/store.d.ts +15 -2
  36. package/package.json +17 -17
  37. package/src/fields/field-services-types.ts +10 -2
  38. package/src/forms/available-locales-widget.tsx +5 -2
  39. package/src/forms/document-actions.tsx +55 -20
  40. package/src/forms/form-renderer-submit.test.tsx +131 -2
  41. package/src/forms/form-renderer.module.css +20 -0
  42. package/src/forms/form-renderer.tsx +122 -7
  43. package/src/forms/form-status-display.tsx +6 -1
  44. package/src/forms/path-widget.tsx +8 -3
  45. package/src/forms/scheduled-publication-control.tsx +16 -7
  46. package/src/forms/tree-placement-widget.tsx +34 -7
  47. package/src/modules/admin-account/components/change-password.test.tsx +72 -0
  48. package/src/modules/admin-account/components/change-password.tsx +15 -4
  49. package/src/modules/admin-account/service.ts +8 -7
  50. package/src/modules/admin-users/repository.ts +10 -1
  51. package/src/modules/auth/index.ts +13 -1
  52. package/src/modules/auth/jwt-session-provider.ts +276 -91
  53. package/src/modules/auth/login-sessions-repository.ts +25 -0
  54. package/src/modules/auth/refresh-tokens-repository.ts +12 -9
  55. package/src/modules/auth/resolve-actor.ts +10 -1
  56. package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
  57. package/src/store.ts +22 -2
@@ -20,6 +20,10 @@ import { RelationPicker } from '../fields/relation/relation-picker.js'
20
20
  import styles from './tree-placement-widget.module.css'
21
21
 
22
22
  export interface TreePlacementWidgetProps {
23
+ disabled?: boolean
24
+ onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void
25
+ onCommitted?: (receipt: import('@byline/core').StructuralMutationReceipt) => void
26
+ expectedRevision: number
23
27
  /** The collection path (`tree: true`). */
24
28
  collectionPath: string
25
29
  /** The logical id of the document being edited. */
@@ -44,6 +48,10 @@ export interface TreePlacementWidgetProps {
44
48
  */
45
49
  export const TreePlacementWidget = ({
46
50
  collectionPath,
51
+ disabled = false,
52
+ onMutationError,
53
+ onCommitted,
54
+ expectedRevision,
47
55
  documentId,
48
56
  useAsTitle,
49
57
  }: TreePlacementWidgetProps) => {
@@ -99,7 +107,7 @@ export const TreePlacementWidget = ({
99
107
  // placement (including making the node a root) leaves it placed.
100
108
  const place = useCallback(
101
109
  async (parentDocumentId: string | null, optimistic: { id: string; title: string } | null) => {
102
- if (placeTreeNode == null || busy) return
110
+ if (disabled || placeTreeNode == null || busy) return
103
111
  const previousParent = parent
104
112
  const previousPlaced = placed
105
113
  setError(null)
@@ -107,8 +115,15 @@ export const TreePlacementWidget = ({
107
115
  setParent(optimistic)
108
116
  setPlaced(true)
109
117
  try {
110
- await placeTreeNode({ collection: collectionPath, documentId, parentDocumentId })
111
- } catch {
118
+ const result = await placeTreeNode({
119
+ expectedRevision,
120
+ collection: collectionPath,
121
+ documentId,
122
+ parentDocumentId,
123
+ })
124
+ onCommitted?.(result)
125
+ } catch (error) {
126
+ if (onMutationError?.(error) === 'committed') return
112
127
  setParent(previousParent)
113
128
  setPlaced(previousPlaced)
114
129
  setError(t('treeWidget.error'))
@@ -116,7 +131,19 @@ export const TreePlacementWidget = ({
116
131
  setBusy(false)
117
132
  }
118
133
  },
119
- [placeTreeNode, busy, parent, placed, collectionPath, documentId, t]
134
+ [
135
+ disabled,
136
+ onMutationError,
137
+ onCommitted,
138
+ expectedRevision,
139
+ placeTreeNode,
140
+ busy,
141
+ parent,
142
+ placed,
143
+ collectionPath,
144
+ documentId,
145
+ t,
146
+ ]
120
147
  )
121
148
 
122
149
  const handlePick = useCallback(
@@ -156,7 +183,7 @@ export const TreePlacementWidget = ({
156
183
  size="xs"
157
184
  variant="outlined"
158
185
  intent="noeffect"
159
- disabled={loading || busy}
186
+ disabled={disabled || loading || busy}
160
187
  onClick={() => setPickerOpen(true)}
161
188
  >
162
189
  {t('treeWidget.choose')}
@@ -165,7 +192,7 @@ export const TreePlacementWidget = ({
165
192
  <button
166
193
  type="button"
167
194
  className={cx('byline-form-tree-link', styles.link)}
168
- disabled={loading || busy}
195
+ disabled={disabled || loading || busy}
169
196
  onClick={() => place(null, null)}
170
197
  >
171
198
  {t('treeWidget.addToTree')}
@@ -175,7 +202,7 @@ export const TreePlacementWidget = ({
175
202
  <button
176
203
  type="button"
177
204
  className={cx('byline-form-tree-link', styles.link)}
178
- disabled={busy}
205
+ disabled={disabled || busy}
179
206
  onClick={() => place(null, null)}
180
207
  >
181
208
  {t('treeWidget.makeRoot')}
@@ -0,0 +1,72 @@
1
+ import { act } from 'react'
2
+
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ change: vi.fn(),
8
+ submit: null as
9
+ | null
10
+ | ((args: {
11
+ value: { currentPassword: string; newPassword: string; confirm: string }
12
+ }) => Promise<void>),
13
+ }))
14
+ vi.mock('@tanstack/react-form-start', () => ({
15
+ revalidateLogic: () => ({}),
16
+ useForm: (options: { onSubmit: typeof mocks.submit }) => {
17
+ mocks.submit = options.onSubmit
18
+ return { reset: vi.fn(), Field: () => null, Subscribe: () => null }
19
+ },
20
+ }))
21
+ vi.mock('../../../services/admin-services-context.js', () => ({
22
+ useBylineAdminServices: () => ({ changeAccountPassword: mocks.change }),
23
+ }))
24
+ vi.mock('@byline/i18n/react', () => ({
25
+ useTranslation: () => ({
26
+ t: (key: string) =>
27
+ ({
28
+ 'account.changePassword.feedback.updated': 'Password updated.',
29
+ 'auth.signIn.title': 'Sign in',
30
+ })[key] ?? key,
31
+ }),
32
+ }))
33
+ vi.mock('@byline/ui/react', () => ({
34
+ Alert: ({ children, role }: { children: React.ReactNode; role?: string }) => (
35
+ <div role={role}>{children}</div>
36
+ ),
37
+ Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
38
+ <button type="button" onClick={onClick}>
39
+ {children}
40
+ </button>
41
+ ),
42
+ InputPassword: () => null,
43
+ LoaderEllipsis: () => null,
44
+ }))
45
+
46
+ import { ChangeAccountPassword } from './change-password.js'
47
+ import type { AccountResponse } from '../index.js'
48
+
49
+ let root: Root
50
+ let container: HTMLDivElement
51
+ beforeEach(() => {
52
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
53
+ vi.clearAllMocks()
54
+ container = document.createElement('div')
55
+ document.body.appendChild(container)
56
+ root = createRoot(container)
57
+ })
58
+ afterEach(async () => {
59
+ await act(async () => root.unmount())
60
+ container.remove()
61
+ })
62
+ it('keeps success confirmation and a sign-in action visible after completion', async () => {
63
+ const account = { id: 'account', vid: 1 } as AccountResponse
64
+ mocks.change.mockResolvedValue({ ...account, vid: 2 })
65
+ await act(async () => root.render(<ChangeAccountPassword account={account} />))
66
+ await act(async () => {
67
+ await mocks.submit?.({ value: { currentPassword: 'old', newPassword: 'new', confirm: 'new' } })
68
+ })
69
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('Password updated.')
70
+ expect(container.querySelector('button')?.textContent).toBe('Sign in')
71
+ expect(container.querySelector('form')).toBeNull()
72
+ })
@@ -19,10 +19,8 @@
19
19
  * password surfaces as `admin.account.invalidCurrentPassword`.
20
20
  * - Confirmation field catches typos before round-trip.
21
21
  *
22
- * Caveat: changing the password here does not revoke other active
23
- * sessions today. Existing access tokens stay valid until expiry
24
- * (~15 min); a "sign out everywhere on password change" follow-up
25
- * will close that gap.
22
+ * Successful password changes end native sessions. Confirmation remains visible
23
+ * until the user reloads the protected account page to reach sign-in.
26
24
  */
27
25
 
28
26
  import { useMemo, useState } from 'react'
@@ -127,6 +125,19 @@ export function ChangeAccountPassword({ account, onClose, onSuccess }: ChangePas
127
125
  },
128
126
  })
129
127
 
128
+ if (successMessage) {
129
+ return (
130
+ <div className={cx('byline-account-change-password-wrap', styles.wrap)}>
131
+ <div role="status">
132
+ <Alert intent="success">{successMessage}</Alert>
133
+ </div>
134
+ <Button type="button" intent="primary" onClick={() => window.location.reload()}>
135
+ {t('auth.signIn.title')}
136
+ </Button>
137
+ </div>
138
+ )
139
+ }
140
+
130
141
  return (
131
142
  <div className={cx('byline-account-change-password-wrap', styles.wrap)}>
132
143
  <form
@@ -7,7 +7,10 @@
7
7
  */
8
8
 
9
9
  import { toAdminUser } from '../admin-users/dto.js'
10
- import { ERR_ADMIN_USER_EMAIL_IN_USE } from '../admin-users/errors.js'
10
+ import {
11
+ ERR_ADMIN_USER_EMAIL_IN_USE,
12
+ ERR_ADMIN_USER_VERSION_CONFLICT,
13
+ } from '../admin-users/errors.js'
11
14
  import { hashPassword, verifyPassword } from '../auth/password.js'
12
15
  import {
13
16
  ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD,
@@ -38,12 +41,8 @@ import type {
38
41
  * in the new hash. A hijacked session cannot use this flow to lock
39
42
  * out the legitimate owner.
40
43
  *
41
- * Note on session revocation: changing a password here does **not**
42
- * currently revoke other refresh tokens existing access tokens stay
43
- * valid until their 15-minute expiry, and other refresh tokens remain
44
- * useable. A "sign out everywhere on password change" follow-up should
45
- * call `RefreshTokensRepository.revokeAllExcept(adminUserId, currentJti)`
46
- * once that lands.
44
+ * Native adapter password writes atomically advance the session generation and
45
+ * revoke every refresh session. External providers own their own revocation.
47
46
  */
48
47
  export class AdminAccountService {
49
48
  readonly #repo: AdminUsersRepository
@@ -97,6 +96,8 @@ export class AdminAccountService {
97
96
  const ok = await verifyPassword(request.currentPassword, withHash.password_hash)
98
97
  if (!ok) throw ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD()
99
98
 
99
+ if (withHash.vid !== request.vid) throw ERR_ADMIN_USER_VERSION_CONFLICT()
100
+
100
101
  const newHash = await hashPassword(request.newPassword)
101
102
  const row = await this.#repo.setPasswordHash(actorId, request.vid, newHash)
102
103
  return toAdminUser(row)
@@ -68,6 +68,8 @@ export interface AdminUserRow {
68
68
  */
69
69
  export interface AdminUserWithPasswordRow extends AdminUserRow {
70
70
  password_hash: string
71
+ /** Native session generation, independent of edit revisions. */
72
+ session_version: number
71
73
  }
72
74
 
73
75
  export interface CreateAdminUserInput {
@@ -158,16 +160,23 @@ export interface AdminUsersRepository {
158
160
  * Content update with optimistic concurrency. Throws
159
161
  * `AdminUsersError(VERSION_CONFLICT)` if the stored `vid` differs from
160
162
  * `expectedVid`. Bumps `vid` on success and returns the fresh row.
163
+ * A false `is_enabled` patch must atomically advance the native session
164
+ * generation and revoke refresh sessions, under the account row lock.
161
165
  */
162
166
  update(id: string, expectedVid: number, patch: UpdateAdminUserInput): Promise<AdminUserRow>
163
167
  /**
164
168
  * Replace the stored password hash with optimistic concurrency.
165
169
  * Version-gated on `expectedVid`. Caller supplies a pre-hashed PHC string.
170
+ * Atomically advance the native session generation and revoke every refresh
171
+ * session in the same transaction. Lock the account before refresh rows.
166
172
  * Returns the updated row so callers holding the edit form can refresh
167
173
  * their cached `vid` without a second round-trip.
168
174
  */
169
175
  setPasswordHash(id: string, expectedVid: number, passwordHash: string): Promise<AdminUserRow>
170
- /** Toggle enabled state. Vid-less — admin intent is independent of other edits. */
176
+ /**
177
+ * Toggle enabled state. Disable must atomically advance the native session
178
+ * generation and revoke refresh sessions. Enable never resets the generation.
179
+ */
171
180
  setEnabled(id: string, enabled: boolean): Promise<void>
172
181
  /**
173
182
  * Set the admin interface locale preference. Vid-less — user preference
@@ -21,9 +21,21 @@
21
21
  * against `@byline/auth` rather than added here.
22
22
  */
23
23
 
24
- export { JwtSessionProvider, type JwtSessionProviderConfig } from './jwt-session-provider.js'
24
+ export {
25
+ JwtSessionProvider,
26
+ type JwtSessionProviderConfig,
27
+ type NativeSessionEvent,
28
+ } from './jwt-session-provider.js'
25
29
  export { hashPassword, verifyPassword } from './password.js'
26
30
  export { resolveActor } from './resolve-actor.js'
31
+ export {
32
+ createPasswordSignInLimiter,
33
+ type SignInLimiterOptions,
34
+ type SignInRateLimitPolicy,
35
+ type SignInRateLimitStore,
36
+ type SignInSecurityEvent,
37
+ } from './sign-in-rate-limiter.js'
38
+ export type { LoginSessionRow, LoginSessionsRepository } from './login-sessions-repository.js'
27
39
  export type {
28
40
  IssueRefreshTokenInput,
29
41
  RefreshTokenRow,