@byline/admin 5.0.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 (34) hide show
  1. package/dist/fields/field-services-context.d.ts +1 -1
  2. package/dist/forms/available-locales-widget.d.ts +1 -1
  3. package/dist/forms/document-actions.d.ts +1 -1
  4. package/dist/forms/form-renderer.d.ts +3 -3
  5. package/dist/forms/form-status-display.d.ts +1 -1
  6. package/dist/forms/upload-executor.d.ts +2 -2
  7. package/dist/modules/admin-account/components/change-password.js +18 -0
  8. package/dist/modules/admin-account/service.d.ts +2 -6
  9. package/dist/modules/admin-account/service.js +2 -1
  10. package/dist/modules/admin-users/repository.d.ts +10 -1
  11. package/dist/modules/auth/index.d.ts +3 -1
  12. package/dist/modules/auth/index.js +1 -0
  13. package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
  14. package/dist/modules/auth/jwt-session-provider.js +222 -70
  15. package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
  16. package/dist/modules/auth/login-sessions-repository.js +1 -0
  17. package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
  18. package/dist/modules/auth/resolve-actor.d.ts +3 -0
  19. package/dist/modules/auth/resolve-actor.js +5 -2
  20. package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
  21. package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
  22. package/dist/store.d.ts +15 -2
  23. package/package.json +17 -17
  24. package/src/modules/admin-account/components/change-password.test.tsx +72 -0
  25. package/src/modules/admin-account/components/change-password.tsx +15 -4
  26. package/src/modules/admin-account/service.ts +8 -7
  27. package/src/modules/admin-users/repository.ts +10 -1
  28. package/src/modules/auth/index.ts +13 -1
  29. package/src/modules/auth/jwt-session-provider.ts +276 -91
  30. package/src/modules/auth/login-sessions-repository.ts +25 -0
  31. package/src/modules/auth/refresh-tokens-repository.ts +12 -9
  32. package/src/modules/auth/resolve-actor.ts +10 -1
  33. package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
  34. package/src/store.ts +22 -2
@@ -19,6 +19,9 @@ export interface RefreshTokenRow {
19
19
  id: string;
20
20
  admin_user_id: string;
21
21
  token_hash: string;
22
+ /** Null only for legacy rows, which cannot authorize native sessions. */
23
+ sid: string | null;
24
+ session_version: number;
22
25
  issued_at: Date;
23
26
  expires_at: Date;
24
27
  revoked_at: Date | null;
@@ -28,9 +31,13 @@ export interface RefreshTokenRow {
28
31
  ip: string | null;
29
32
  }
30
33
  export interface IssueRefreshTokenInput {
34
+ /** Omitted only by legacy fixtures/imports; never accepted for native renewal. */
35
+ sid?: string | null;
31
36
  id: string;
32
37
  admin_user_id: string;
33
38
  token_hash: string;
39
+ /** Account generation observed under the native issuance lock. */
40
+ session_version: number;
34
41
  expires_at: Date;
35
42
  user_agent?: string | null;
36
43
  ip?: string | null;
@@ -43,19 +50,15 @@ export interface RefreshTokensRepository {
43
50
  /** Stamp `last_used_at` for observability. */
44
51
  touch(id: string, at?: Date): Promise<void>;
45
52
  /**
46
- * Atomically revoke `oldId` and set its `rotated_to_id` to `newId`.
47
- * Caller is responsible for inserting the new row (via `issue`) before
48
- * calling this ordering is a contract.
53
+ * Revoke `oldId` and set its `rotated_to_id` to `newId`. This is not
54
+ * independently a compare-and-swap. Native callers must hold the account
55
+ * lock, reread the predecessor, and insert the successor before this write,
56
+ * all through the same `withSessionLock` transaction.
49
57
  */
50
58
  markRotated(oldId: string, newId: string, at?: Date): Promise<void>;
51
59
  /** Revoke a single token. Idempotent. */
52
60
  revoke(id: string, at?: Date): Promise<void>;
53
- /**
54
- * Walk the rotation chain starting at `startId` and revoke every token
55
- * in it. Called when a rotated token is replayed — indicates the chain
56
- * has been compromised and every descendant is suspect. Returns the
57
- * number of rows touched.
58
- */
61
+ /** Revoke all refresh rows sharing the start member's sid, without traversal. */
59
62
  revokeChain(startId: string, at?: Date): Promise<number>;
60
63
  /** Revoke every non-revoked token for a user. Used on password change / sign-out everywhere. */
61
64
  revokeAllForUser(adminUserId: string, at?: Date): Promise<number>;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { AdminAuth } from '@byline/auth';
9
9
  import type { AdminStore } from '../../store.js';
10
+ import type { AdminUserRow } from '../admin-users/repository.js';
10
11
  /**
11
12
  * Build an `AdminAuth` from a user id by reading the admin-users row and
12
13
  * collecting the distinct abilities granted through every role the user
@@ -22,3 +23,5 @@ import type { AdminStore } from '../../store.js';
22
23
  * `AdminStore` bundle; adapter-agnostic.
23
24
  */
24
25
  export declare function resolveActor(store: AdminStore, adminUserId: string): Promise<AdminAuth | null>;
26
+ /** Internal helper: reuse the account snapshot already checked by native authentication. */
27
+ export declare function resolveActorFromUser(store: AdminStore, user: AdminUserRow | null): Promise<AdminAuth | null>;
@@ -1,13 +1,16 @@
1
1
  import { AdminAuth } from "@byline/auth";
2
2
  async function resolveActor(store, adminUserId) {
3
3
  const user = await store.adminUsers.getById(adminUserId);
4
+ return resolveActorFromUser(store, user);
5
+ }
6
+ async function resolveActorFromUser(store, user) {
4
7
  if (!user) return null;
5
8
  if (!user.is_enabled) return null;
6
- const abilities = await store.adminPermissions.listAbilitiesForUser(adminUserId);
9
+ const abilities = await store.adminPermissions.listAbilitiesForUser(user.id);
7
10
  return new AdminAuth({
8
11
  id: user.id,
9
12
  abilities,
10
13
  isSuperAdmin: user.is_super_admin
11
14
  });
12
15
  }
13
- export { resolveActor };
16
+ export { resolveActor, resolveActorFromUser };
@@ -0,0 +1,48 @@
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
+ import type { PasswordSignInLimiter } from '@byline/auth';
9
+ import type { RecurringTaskDefinition } from '@byline/core';
10
+ export interface SignInRateLimitStore {
11
+ /** Atomically increment, saturating at limit + 1; true only for the first limit calls. */
12
+ consume(key: string, limit: number, expiresAt: Date): Promise<boolean>;
13
+ /** Remove at most 100 expired counters and return the number removed. */
14
+ purgeExpired(before: Date): Promise<number>;
15
+ }
16
+ export interface SignInRateLimitPolicy {
17
+ account: {
18
+ limit: number;
19
+ windowSeconds: number;
20
+ };
21
+ ip: {
22
+ limit: number;
23
+ windowSeconds: number;
24
+ };
25
+ }
26
+ export interface SignInSecurityEvent {
27
+ type: 'admitted' | 'denied' | 'capacity' | 'store-error' | 'cleanup-error' | 'success' | 'failure';
28
+ scope?: 'account' | 'ip';
29
+ /** Stable, pseudonymous digests for cross-network correlation. Never passwords or raw IPs. */
30
+ account?: string;
31
+ network?: string;
32
+ }
33
+ export interface SignInLimiterOptions {
34
+ onEvent?: (event: SignInSecurityEvent) => void;
35
+ slots?: number;
36
+ maxQueue?: number;
37
+ queueTimeoutMs?: number;
38
+ /** Also bounds counter creation when the provider or a denial completes very quickly. */
39
+ minimumSlotMs?: number;
40
+ }
41
+ /**
42
+ * Instantiate once per process. Acquire before consume and release in finally after verification.
43
+ * Network admission precedes account-plus-network admission. There is no account-wide lockout.
44
+ */
45
+ export declare function createPasswordSignInLimiter(store: SignInRateLimitStore, secret: string | Uint8Array, policy?: SignInRateLimitPolicy, now?: () => number, options?: SignInLimiterOptions): PasswordSignInLimiter & {
46
+ cleanupTask: RecurringTaskDefinition;
47
+ dispose(): void;
48
+ };
@@ -0,0 +1,229 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ const DEFAULT_POLICY = {
4
+ account: {
5
+ limit: 10,
6
+ windowSeconds: 900
7
+ },
8
+ ip: {
9
+ limit: 60,
10
+ windowSeconds: 60
11
+ }
12
+ };
13
+ const CLEANUP_TASK_NAME = 'auth.sign-in-counters.cleanup';
14
+ function signInNetwork(ip) {
15
+ const version = isIP(ip);
16
+ if (4 === version) return ip;
17
+ if (6 !== version || ip.includes('%')) throw new Error('Invalid client address');
18
+ const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
19
+ const mapped = /^::ffff:([0-9a-f]+):([0-9a-f]+)$/.exec(canonical);
20
+ if (mapped) {
21
+ const high = Number.parseInt(mapped[1], 16);
22
+ const low = Number.parseInt(mapped[2], 16);
23
+ return [
24
+ high >>> 8,
25
+ 255 & high,
26
+ low >>> 8,
27
+ 255 & low
28
+ ].join('.');
29
+ }
30
+ const [left, right] = canonical.split('::');
31
+ const head = left ? left.split(':') : [];
32
+ const tail = right ? right.split(':') : [];
33
+ const words = void 0 === right ? head : [
34
+ ...head,
35
+ ...Array(8 - head.length - tail.length).fill('0'),
36
+ ...tail
37
+ ];
38
+ return `${words.slice(0, 4).join(':')}::/64`;
39
+ }
40
+ function createPasswordSignInLimiter(store, secret, policy = DEFAULT_POLICY, now = Date.now, options = {}) {
41
+ const secretBytes = 'string' == typeof secret ? new TextEncoder().encode(secret) : secret;
42
+ if (secretBytes.byteLength < 32) throw new Error('Sign-in HMAC secret must contain at least 32 bytes');
43
+ const key = createHmac('sha256', secretBytes).update('byline:password-sign-in:v1').digest();
44
+ const digest = (value)=>createHmac('sha256', key).update(JSON.stringify(value)).digest('hex');
45
+ const rules = structuredClone(policy);
46
+ for (const rule of Object.values(rules))if (!Number.isSafeInteger(rule.limit) || rule.limit < 1 || rule.limit > 1000000 || !Number.isSafeInteger(rule.windowSeconds) || rule.windowSeconds < 1 || rule.windowSeconds > 86400) throw new Error('Invalid password sign-in rate limit policy');
47
+ const slots = options.slots ?? 1;
48
+ const maxQueue = options.maxQueue ?? 4;
49
+ const queueTimeoutMs = options.queueTimeoutMs ?? 250;
50
+ const minimumSlotMs = options.minimumSlotMs ?? 100;
51
+ for (const value of [
52
+ slots,
53
+ queueTimeoutMs,
54
+ minimumSlotMs
55
+ ])if (!Number.isSafeInteger(value) || value < 1 || value > 60000) throw new Error('Invalid sign-in capacity policy');
56
+ if (!Number.isSafeInteger(maxQueue) || maxQueue < 0 || maxQueue > 1000) throw new Error('Invalid sign-in queue size');
57
+ const diagnostics = new Map();
58
+ const emit = (event)=>{
59
+ try {
60
+ if (options.onEvent) options.onEvent(event);
61
+ else if ('admitted' !== event.type && 'success' !== event.type) {
62
+ const entry = diagnostics.get(event.type) ?? {
63
+ last: -1 / 0,
64
+ count: 0
65
+ };
66
+ entry.count++;
67
+ if (performance.now() - entry.last >= 10000) {
68
+ console.warn('[byline:password-sign-in]', {
69
+ ...event,
70
+ count: entry.count
71
+ });
72
+ entry.last = performance.now();
73
+ entry.count = 0;
74
+ }
75
+ diagnostics.set(event.type, entry);
76
+ }
77
+ } catch {}
78
+ };
79
+ const identifiers = ({ email, ip })=>({
80
+ account: digest([
81
+ 'account',
82
+ email.trim().toLowerCase()
83
+ ]),
84
+ network: digest([
85
+ 'network',
86
+ signInNetwork(ip)
87
+ ])
88
+ });
89
+ let active = 0;
90
+ let disposed = false;
91
+ const queue = [];
92
+ const lease = ()=>{
93
+ active++;
94
+ const started = performance.now();
95
+ let released = false;
96
+ return ()=>{
97
+ if (released) return;
98
+ released = true;
99
+ setTimeout(()=>{
100
+ active--;
101
+ queue.shift()?.grant();
102
+ }, Math.max(0, minimumSlotMs - (performance.now() - started))).unref();
103
+ };
104
+ };
105
+ const cleanupTask = {
106
+ name: CLEANUP_TASK_NAME,
107
+ intervalMs: 60000,
108
+ leaseMs: 60000,
109
+ async run (context) {
110
+ const cutoff = new Date(now() - 300000);
111
+ try {
112
+ for(let batch = 0; batch < 32; batch++){
113
+ context.signal.throwIfAborted();
114
+ await context.heartbeat();
115
+ if (await store.purgeExpired(cutoff) < 100) return {
116
+ workRemaining: false
117
+ };
118
+ }
119
+ return {
120
+ workRemaining: true
121
+ };
122
+ } catch (error) {
123
+ emit({
124
+ type: 'cleanup-error'
125
+ });
126
+ throw error;
127
+ }
128
+ }
129
+ };
130
+ return {
131
+ requiredCleanupTask: CLEANUP_TASK_NAME,
132
+ cleanupTask,
133
+ dispose () {
134
+ disposed = true;
135
+ for (const waiter of queue.splice(0))waiter.cancel();
136
+ },
137
+ async acquire () {
138
+ if (disposed) throw new Error('Sign-in limiter disposed');
139
+ if (active < slots) return lease();
140
+ if (queue.length >= maxQueue) {
141
+ emit({
142
+ type: 'capacity'
143
+ });
144
+ return null;
145
+ }
146
+ return new Promise((resolve)=>{
147
+ const waiter = {
148
+ grant: ()=>{
149
+ clearTimeout(timeout);
150
+ resolve(lease());
151
+ },
152
+ cancel: ()=>{
153
+ clearTimeout(timeout);
154
+ emit({
155
+ type: 'capacity'
156
+ });
157
+ resolve(null);
158
+ }
159
+ };
160
+ const timeout = setTimeout(()=>{
161
+ queue.splice(queue.indexOf(waiter), 1);
162
+ waiter.cancel();
163
+ }, queueTimeoutMs);
164
+ queue.push(waiter);
165
+ });
166
+ },
167
+ recordResult (input, outcome) {
168
+ emit({
169
+ type: outcome,
170
+ ...identifiers(input)
171
+ });
172
+ },
173
+ async consume (input) {
174
+ const time = now();
175
+ const network = signInNetwork(input.ip);
176
+ const ids = identifiers(input);
177
+ try {
178
+ await store.purgeExpired(new Date(time - 300000));
179
+ for (const [scope, identity] of [
180
+ [
181
+ 'ip',
182
+ network
183
+ ],
184
+ [
185
+ 'account',
186
+ [
187
+ input.email.trim().toLowerCase(),
188
+ network
189
+ ]
190
+ ]
191
+ ]){
192
+ const rule = rules[scope];
193
+ const windowMs = 1000 * rule.windowSeconds;
194
+ const end = (Math.floor(time / windowMs) + 1) * windowMs;
195
+ if (!await store.consume(digest([
196
+ scope,
197
+ identity,
198
+ end
199
+ ]), rule.limit, new Date(end))) {
200
+ emit({
201
+ type: 'denied',
202
+ scope,
203
+ ...ids
204
+ });
205
+ return {
206
+ allowed: false,
207
+ retryAfterSeconds: Math.max(1, Math.ceil((end - time) / 1000))
208
+ };
209
+ }
210
+ }
211
+ emit({
212
+ type: 'admitted',
213
+ ...ids
214
+ });
215
+ return {
216
+ allowed: true,
217
+ retryAfterSeconds: 0
218
+ };
219
+ } catch (error) {
220
+ emit({
221
+ type: 'store-error',
222
+ ...ids
223
+ });
224
+ throw error;
225
+ }
226
+ }
227
+ };
228
+ }
229
+ export { createPasswordSignInLimiter };
package/dist/store.d.ts CHANGED
@@ -8,8 +8,10 @@
8
8
  import type { AdminPermissionsRepository } from './modules/admin-permissions/repository.js';
9
9
  import type { AdminPreferencesRepository } from './modules/admin-preferences/repository.js';
10
10
  import type { AdminRolesRepository } from './modules/admin-roles/repository.js';
11
- import type { AdminUsersRepository } from './modules/admin-users/repository.js';
11
+ import type { AdminUsersRepository, AdminUserWithPasswordRow } from './modules/admin-users/repository.js';
12
+ import type { LoginSessionsRepository } from './modules/auth/login-sessions-repository.js';
12
13
  import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repository.js';
14
+ import type { SignInRateLimitStore } from './modules/auth/sign-in-rate-limiter.js';
13
15
  /**
14
16
  * The bundle of repositories that `@byline/admin` needs from the DB
15
17
  * adapter. A DB adapter package (`@byline/db-postgres`, a future
@@ -19,11 +21,22 @@ import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repo
19
21
  * `JwtSessionProvider`, to `seedSuperAdmin`, and (later) to admin-user
20
22
  * and admin-role commands.
21
23
  *
22
- * Keeping the five repositories together as a single argument avoids
24
+ * Keeping the repositories together as a single argument avoids
23
25
  * exploding constructor signatures and makes "needs admin DB access" a
24
26
  * single, recognisable type.
25
27
  */
26
28
  export interface AdminStore {
29
+ /**
30
+ * Serialize native issuance/revocation against account mutations. Lock the
31
+ * account row first, then operate on refresh rows through the scoped store.
32
+ * The callback and all its writes commit together or roll back together.
33
+ * Never retain the scoped repositories beyond the callback. No automatic retries.
34
+ */
35
+ withSessionLock<T>(adminUserId: string, work: (store: AdminStore, user: AdminUserWithPasswordRow | null) => Promise<T>): Promise<T>;
36
+ /** Lock distinct account IDs in sorted order in one transaction for account-switch sign-in. */
37
+ withSessionLocks<T>(adminUserIds: string[], work: (store: AdminStore) => Promise<T>): Promise<T>;
38
+ loginSessions: LoginSessionsRepository;
39
+ signInRateLimits: SignInRateLimitStore;
27
40
  adminUsers: AdminUsersRepository;
28
41
  adminRoles: AdminRolesRepository;
29
42
  adminPermissions: AdminPermissionsRepository;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "5.0.0",
5
+ "version": "5.1.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -174,40 +174,40 @@
174
174
  "@codemirror/lang-sql": "^6.10.0",
175
175
  "@codemirror/lang-yaml": "^6.1.3",
176
176
  "@codemirror/language": "^6.12.4",
177
- "@codemirror/state": "^6.7.1",
178
- "@codemirror/view": "^6.43.9",
177
+ "@codemirror/state": "^6.7.4",
178
+ "@codemirror/view": "^6.43.11",
179
179
  "@lezer/highlight": "^1.2.3",
180
180
  "@tanstack/react-form-start": "^1.33.5",
181
181
  "clsx": "^2.1.1",
182
- "jose": "^6.2.10",
182
+ "jose": "^6.2.12",
183
183
  "react-diff-viewer-continued": "^4.4.0",
184
184
  "uuid": "^14.0.2",
185
- "zod": "^4.4.3",
186
- "@byline/core": "5.0.0",
187
- "@byline/analytics": "5.0.0",
188
- "@byline/i18n": "5.0.0",
189
- "@byline/ui": "5.0.0",
190
- "@byline/auth": "5.0.0",
191
- "@byline/analytics-agent": "5.0.0"
185
+ "zod": "^4.5.4",
186
+ "@byline/analytics": "5.1.0",
187
+ "@byline/analytics-agent": "5.1.0",
188
+ "@byline/core": "5.1.0",
189
+ "@byline/auth": "5.1.0",
190
+ "@byline/ui": "5.1.0",
191
+ "@byline/i18n": "5.1.0"
192
192
  },
193
193
  "peerDependencies": {
194
194
  "react": "^19.0.0",
195
195
  "react-dom": "^19.0.0"
196
196
  },
197
197
  "devDependencies": {
198
- "@biomejs/biome": "2.5.10",
198
+ "@biomejs/biome": "2.5.12",
199
199
  "@rsbuild/plugin-react": "^2.1.0",
200
- "@rslib/core": "^0.23.2",
201
- "@types/node": "^26.4.0",
200
+ "@rslib/core": "^1.0.0",
201
+ "@types/node": "^26.5.0",
202
202
  "@types/react": "19.2.18",
203
- "@types/react-dom": "19.2.5",
204
- "@vitejs/plugin-react": "^6.1.0",
203
+ "@types/react-dom": "19.2.7",
204
+ "@vitejs/plugin-react": "^6.1.1",
205
205
  "jsdom": "^30.0.1",
206
206
  "react": "^19.2.8",
207
207
  "react-dom": "^19.2.8",
208
208
  "typescript": "^7.0.2",
209
209
  "typescript-plugin-css-modules": "^5.2.0",
210
- "vitest": "^4.1.11"
210
+ "vitest": "^5.0.0"
211
211
  },
212
212
  "publishConfig": {
213
213
  "access": "public",
@@ -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,