@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.
- package/dist/fields/field-services-context.d.ts +1 -1
- package/dist/fields/field-services-types.d.ts +5 -2
- package/dist/forms/available-locales-widget.d.ts +3 -2
- package/dist/forms/available-locales-widget.js +4 -2
- package/dist/forms/document-actions.d.ts +3 -2
- package/dist/forms/document-actions.js +32 -14
- package/dist/forms/form-renderer.d.ts +12 -3
- package/dist/forms/form-renderer.js +98 -8
- package/dist/forms/form-renderer.module.js +1 -0
- package/dist/forms/form-renderer_module.css +12 -0
- package/dist/forms/form-status-display.d.ts +3 -2
- package/dist/forms/form-status-display.js +5 -2
- package/dist/forms/path-widget.d.ts +2 -1
- package/dist/forms/path-widget.js +7 -2
- package/dist/forms/scheduled-publication-control.d.ts +2 -1
- package/dist/forms/scheduled-publication-control.js +12 -8
- package/dist/forms/tree-placement-widget.d.ts +5 -1
- package/dist/forms/tree-placement-widget.js +14 -7
- package/dist/forms/upload-executor.d.ts +2 -2
- package/dist/modules/admin-account/components/change-password.js +18 -0
- package/dist/modules/admin-account/service.d.ts +2 -6
- package/dist/modules/admin-account/service.js +2 -1
- package/dist/modules/admin-users/repository.d.ts +10 -1
- package/dist/modules/auth/index.d.ts +3 -1
- package/dist/modules/auth/index.js +1 -0
- package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
- package/dist/modules/auth/jwt-session-provider.js +222 -70
- package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
- package/dist/modules/auth/login-sessions-repository.js +1 -0
- package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
- package/dist/modules/auth/resolve-actor.d.ts +3 -0
- package/dist/modules/auth/resolve-actor.js +5 -2
- package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
- package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
- package/dist/store.d.ts +15 -2
- package/package.json +17 -17
- package/src/fields/field-services-types.ts +10 -2
- package/src/forms/available-locales-widget.tsx +5 -2
- package/src/forms/document-actions.tsx +55 -20
- package/src/forms/form-renderer-submit.test.tsx +131 -2
- package/src/forms/form-renderer.module.css +20 -0
- package/src/forms/form-renderer.tsx +122 -7
- package/src/forms/form-status-display.tsx +6 -1
- package/src/forms/path-widget.tsx +8 -3
- package/src/forms/scheduled-publication-control.tsx +16 -7
- package/src/forms/tree-placement-widget.tsx +34 -7
- package/src/modules/admin-account/components/change-password.test.tsx +72 -0
- package/src/modules/admin-account/components/change-password.tsx +15 -4
- package/src/modules/admin-account/service.ts +8 -7
- package/src/modules/admin-users/repository.ts +10 -1
- package/src/modules/auth/index.ts +13 -1
- package/src/modules/auth/jwt-session-provider.ts +276 -91
- package/src/modules/auth/login-sessions-repository.ts +25 -0
- package/src/modules/auth/refresh-tokens-repository.ts +12 -9
- package/src/modules/auth/resolve-actor.ts +10 -1
- package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
- 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({
|
|
111
|
-
|
|
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
|
-
[
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
-
/**
|
|
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 {
|
|
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,
|