@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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { signInNetwork } from "./sign-in-rate-limiter.js";
|
|
3
|
+
function createPasskeyRequestLimiter(store, secret, now = Date.now) {
|
|
4
|
+
const bytes = 'string' == typeof secret ? new TextEncoder().encode(secret) : secret;
|
|
5
|
+
if (bytes.byteLength < 32) throw new Error('Passkey HMAC secret must contain at least 32 bytes');
|
|
6
|
+
const key = createHmac('sha256', bytes).update('byline:passkey-requests:v1').digest();
|
|
7
|
+
let active = 0;
|
|
8
|
+
const cleanupTask = {
|
|
9
|
+
name: 'auth.sign-in-counters.cleanup',
|
|
10
|
+
intervalMs: 60000,
|
|
11
|
+
leaseMs: 60000,
|
|
12
|
+
async run (context) {
|
|
13
|
+
const started = performance.now();
|
|
14
|
+
for(let n = 0; n < 32 && performance.now() - started < 30000; n++){
|
|
15
|
+
context.signal.throwIfAborted();
|
|
16
|
+
await context.heartbeat();
|
|
17
|
+
if (await store.purgeExpired(new Date(now() - 300000)) < 100) return {
|
|
18
|
+
workRemaining: false
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
workRemaining: true
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
requiredCleanupTask: cleanupTask.name,
|
|
28
|
+
cleanupTask,
|
|
29
|
+
async acquire () {
|
|
30
|
+
if (active >= 4) return null;
|
|
31
|
+
active++;
|
|
32
|
+
const started = performance.now();
|
|
33
|
+
let released = false;
|
|
34
|
+
return ()=>{
|
|
35
|
+
if (released) return;
|
|
36
|
+
released = true;
|
|
37
|
+
setTimeout(()=>{
|
|
38
|
+
active--;
|
|
39
|
+
}, Math.max(0, 100 - (performance.now() - started))).unref();
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
async consume ({ ip }) {
|
|
43
|
+
const network = signInNetwork(ip);
|
|
44
|
+
const time = now();
|
|
45
|
+
const end = (Math.floor(time / 60000) + 1) * 60000;
|
|
46
|
+
const digest = createHmac('sha256', key).update(JSON.stringify([
|
|
47
|
+
'network',
|
|
48
|
+
network,
|
|
49
|
+
end
|
|
50
|
+
])).digest('hex');
|
|
51
|
+
await store.purgeExpired(new Date(time - 300000));
|
|
52
|
+
const allowed = await store.consume(digest, 60, new Date(end));
|
|
53
|
+
return {
|
|
54
|
+
allowed,
|
|
55
|
+
retryAfterSeconds: allowed ? 0 : Math.max(1, Math.ceil((end - time) / 1000))
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export { createPasskeyRequestLimiter };
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
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 { PasskeyAttemptRow, PasskeyClaimInput } from './passkeys-repository.js';
|
|
9
|
+
/** Canonical encoding makes string identity exactly equivalent to WebAuthn byte identity. */
|
|
10
|
+
export declare function assertPasskeyBytes(value: string, maxBytes: number, exactBytes?: number): void;
|
|
11
|
+
export declare function assertPasskeyTokenHash(value: string): void;
|
|
12
|
+
export declare function assertPasskeyAttempt(input: Omit<PasskeyAttemptRow, 'consumed_at'>): void;
|
|
13
|
+
/** Runtime callers must obey the same purpose/action boundary as typed callers. */
|
|
14
|
+
export declare function assertPasskeyClaim(input: PasskeyClaimInput): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
function assertPasskeyBytes(value, maxBytes, exactBytes) {
|
|
2
|
+
if ('string' != typeof value || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('Invalid passkey byte encoding');
|
|
3
|
+
const bytes = Buffer.from(value, 'base64url');
|
|
4
|
+
if (bytes.length > maxBytes || void 0 !== exactBytes && bytes.length !== exactBytes || bytes.toString('base64url') !== value) throw new Error('Invalid passkey byte encoding');
|
|
5
|
+
}
|
|
6
|
+
function assertPasskeyTokenHash(value) {
|
|
7
|
+
if (!/^[a-f0-9]{64}$/.test(value)) throw new Error('Invalid passkey token hash');
|
|
8
|
+
}
|
|
9
|
+
function assertPasskeyAttempt(input) {
|
|
10
|
+
assertPasskeyTokenHash(input.token_hash);
|
|
11
|
+
const lifetime = input.expires_at.getTime() - input.created_at.getTime();
|
|
12
|
+
if (!Number.isFinite(lifetime) || lifetime <= 0 || lifetime > 300000) throw new Error('Invalid passkey attempt lifetime');
|
|
13
|
+
if (![
|
|
14
|
+
'sign-in',
|
|
15
|
+
'fresh-authentication',
|
|
16
|
+
'enrollment',
|
|
17
|
+
'grant'
|
|
18
|
+
].includes(input.purpose)) throw new Error('Invalid passkey attempt purpose');
|
|
19
|
+
if ('grant' === input.purpose) {
|
|
20
|
+
if (null !== input.challenge) throw new Error('Grants cannot contain challenges');
|
|
21
|
+
} else assertPasskeyBytes(input.challenge ?? '', 32, 32);
|
|
22
|
+
if ('sign-in' === input.purpose) {
|
|
23
|
+
if ([
|
|
24
|
+
input.actor_user_id,
|
|
25
|
+
input.actor_login_id,
|
|
26
|
+
input.actor_generation,
|
|
27
|
+
input.action,
|
|
28
|
+
input.target_user_id,
|
|
29
|
+
input.credential_record_id,
|
|
30
|
+
input.proof_credential_id
|
|
31
|
+
].some((value)=>null !== value)) throw new Error('Sign-in attempts cannot carry management authority');
|
|
32
|
+
} else {
|
|
33
|
+
if (!input.actor_user_id || !input.actor_login_id || !Number.isSafeInteger(input.actor_generation) || (input.actor_generation ?? -1) < 0) throw new Error('Management attempts require an acting login and generation');
|
|
34
|
+
if ('enroll' === input.action) {
|
|
35
|
+
if (input.target_user_id !== input.actor_user_id || null !== input.credential_record_id) throw new Error('Enrollment must target the actor');
|
|
36
|
+
} else if ('remove' === input.action) {
|
|
37
|
+
if (!input.target_user_id || !input.credential_record_id || 'enrollment' === input.purpose) throw new Error('Removal must bind its target account and credential');
|
|
38
|
+
} else throw new Error('Management attempts require an action');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function assertPasskeyClaim(input) {
|
|
42
|
+
assertPasskeyTokenHash(input.tokenHash);
|
|
43
|
+
if ('sign-in' === input.purpose) {
|
|
44
|
+
if (void 0 !== input.action || [
|
|
45
|
+
input.binding.actor_user_id,
|
|
46
|
+
input.binding.actor_login_id,
|
|
47
|
+
input.binding.actor_generation
|
|
48
|
+
].some((value)=>null !== value)) throw new Error('Sign-in claims require anonymous binding and no action');
|
|
49
|
+
} else {
|
|
50
|
+
if (![
|
|
51
|
+
'fresh-authentication',
|
|
52
|
+
'enrollment',
|
|
53
|
+
'grant'
|
|
54
|
+
].includes(input.purpose)) throw new Error('Invalid passkey claim purpose');
|
|
55
|
+
if ('enroll' !== input.action && 'remove' !== input.action) throw new Error('Authenticated claims require an action');
|
|
56
|
+
if (!input.binding.actor_user_id || !input.binding.actor_login_id || !Number.isSafeInteger(input.binding.actor_generation) || input.binding.actor_generation < 0) throw new Error('Authenticated claims require an acting login binding');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export { assertPasskeyAttempt, assertPasskeyBytes, assertPasskeyClaim, assertPasskeyTokenHash };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code Form 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 https://mozilla.org/MPL/2.0/.
|
|
5
|
+
* Copyright (c) Infonomic Company Limited
|
|
6
|
+
*/
|
|
7
|
+
/** Internal persistence records. Never return these through account/editor DTOs. */
|
|
8
|
+
export interface PasskeyCredentialRow {
|
|
9
|
+
id: string;
|
|
10
|
+
/** Null after account deletion; the credential identity remains reserved. */
|
|
11
|
+
admin_user_id: string | null;
|
|
12
|
+
/** Canonical unpadded base64url; byte-exact uniqueness includes revoked records. */
|
|
13
|
+
credential_id: string;
|
|
14
|
+
public_key: string;
|
|
15
|
+
counter: number;
|
|
16
|
+
transports: string[];
|
|
17
|
+
backup_eligible: boolean;
|
|
18
|
+
backed_up: boolean;
|
|
19
|
+
name: string;
|
|
20
|
+
created_at: Date;
|
|
21
|
+
last_used_at: Date | null;
|
|
22
|
+
revoked_at: Date | null;
|
|
23
|
+
}
|
|
24
|
+
export interface PasskeyAttemptRow {
|
|
25
|
+
/** SHA-256 hex of the independent bearer identifier; never the identifier itself. */
|
|
26
|
+
token_hash: string;
|
|
27
|
+
purpose: 'sign-in' | 'fresh-authentication' | 'enrollment' | 'grant';
|
|
28
|
+
challenge: string | null;
|
|
29
|
+
actor_user_id: string | null;
|
|
30
|
+
actor_login_id: string | null;
|
|
31
|
+
actor_generation: number | null;
|
|
32
|
+
action: 'enroll' | 'remove' | null;
|
|
33
|
+
target_user_id: string | null;
|
|
34
|
+
credential_record_id: string | null;
|
|
35
|
+
/** Credential used for fresh proof, if any; revalidated at grant use. */
|
|
36
|
+
proof_credential_id: string | null;
|
|
37
|
+
created_at: Date;
|
|
38
|
+
expires_at: Date;
|
|
39
|
+
consumed_at: Date | null;
|
|
40
|
+
}
|
|
41
|
+
export type PasskeyClaimInput = {
|
|
42
|
+
tokenHash: string;
|
|
43
|
+
now: Date;
|
|
44
|
+
} & ({
|
|
45
|
+
purpose: 'sign-in';
|
|
46
|
+
binding: {
|
|
47
|
+
actor_user_id: null;
|
|
48
|
+
actor_login_id: null;
|
|
49
|
+
actor_generation: null;
|
|
50
|
+
};
|
|
51
|
+
action?: never;
|
|
52
|
+
} | {
|
|
53
|
+
purpose: 'fresh-authentication' | 'enrollment' | 'grant';
|
|
54
|
+
binding: {
|
|
55
|
+
actor_user_id: string;
|
|
56
|
+
actor_login_id: string;
|
|
57
|
+
actor_generation: number;
|
|
58
|
+
};
|
|
59
|
+
action: 'enroll' | 'remove';
|
|
60
|
+
});
|
|
61
|
+
export interface PasskeysRepository {
|
|
62
|
+
/** Target lock required; generation and native-login revocation commit together. */
|
|
63
|
+
invalidateAccountLogins(adminUserId: string, at: Date): Promise<void>;
|
|
64
|
+
/** Success requires both account locks; denial/failure records may be written separately. */
|
|
65
|
+
appendRemovalAudit(input: {
|
|
66
|
+
actorId: string;
|
|
67
|
+
targetId: string;
|
|
68
|
+
credentialId: string;
|
|
69
|
+
outcome: 'success' | 'denied' | 'failed';
|
|
70
|
+
}): Promise<void>;
|
|
71
|
+
getUserHandle(adminUserId: string): Promise<string | null>;
|
|
72
|
+
/** Requires the owning account lock. Allocates once; never resets on credential removal. */
|
|
73
|
+
ensureUserHandle(adminUserId: string, candidate: string): Promise<string>;
|
|
74
|
+
/** Includes tombstones: authentication must reject revoked or ownerless records. */
|
|
75
|
+
findCredential(id: string): Promise<PasskeyCredentialRow | null>;
|
|
76
|
+
/** Includes tombstones: authentication must reject revoked or ownerless records. */
|
|
77
|
+
findCredentialByCredentialId(credentialId: string): Promise<PasskeyCredentialRow | null>;
|
|
78
|
+
listCredentials(adminUserId: string): Promise<PasskeyCredentialRow[]>;
|
|
79
|
+
/** All credential writes require the owning account lock and share its transaction. */
|
|
80
|
+
createCredential(input: Omit<PasskeyCredentialRow, 'admin_user_id' | 'revoked_at'> & {
|
|
81
|
+
admin_user_id: string;
|
|
82
|
+
}): Promise<void>;
|
|
83
|
+
updateCredential(id: string, patch: Partial<Pick<PasskeyCredentialRow, 'counter' | 'backed_up' | 'name' | 'last_used_at'>>): Promise<void>;
|
|
84
|
+
revokeCredential(id: string, at: Date): Promise<void>;
|
|
85
|
+
/** Authenticated attempts require the actor lock; sign-in has no account yet. */
|
|
86
|
+
createAttempt(input: Omit<PasskeyAttemptRow, 'consumed_at'>): Promise<void>;
|
|
87
|
+
findAttempt(tokenHash: string): Promise<PasskeyAttemptRow | null>;
|
|
88
|
+
countPendingForLogin(loginId: string, now: Date): Promise<number>;
|
|
89
|
+
/**
|
|
90
|
+
* Root store only: commits consumption in its own short transaction BEFORE returning.
|
|
91
|
+
* Calling through an account-transaction store must throw, never create a savepoint.
|
|
92
|
+
* Exact purpose/binding, unconsumed state and expiry gate the sole winning claim.
|
|
93
|
+
* No automatic retries, even if commit acknowledgement is uncertain.
|
|
94
|
+
*/
|
|
95
|
+
claimAttempt(input: PasskeyClaimInput): Promise<PasskeyAttemptRow | null>;
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -62,7 +62,7 @@ export interface RefreshTokensRepository {
|
|
|
62
62
|
revokeChain(startId: string, at?: Date): Promise<number>;
|
|
63
63
|
/** Revoke every non-revoked token for a user. Used on password change / sign-out everywhere. */
|
|
64
64
|
revokeAllForUser(adminUserId: string, at?: Date): Promise<number>;
|
|
65
|
-
/**
|
|
65
|
+
/** Legacy housekeeping: at most 100 null-sid rows expired for 30 days. Native login members are retained. */
|
|
66
66
|
purgeExpired(now?: Date): Promise<number>;
|
|
67
67
|
/** All non-revoked tokens for a user. Primarily for tests. */
|
|
68
68
|
listActiveForUser(adminUserId: string): Promise<RefreshTokenRow[]>;
|
|
@@ -38,6 +38,8 @@ export interface SignInLimiterOptions {
|
|
|
38
38
|
/** Also bounds counter creation when the provider or a denial completes very quickly. */
|
|
39
39
|
minimumSlotMs?: number;
|
|
40
40
|
}
|
|
41
|
+
/** IPv4 uses one address; IPv6 uses its /64. Mapped IPv4 shares its native IPv4 bucket. */
|
|
42
|
+
export declare function signInNetwork(ip: string): string;
|
|
41
43
|
/**
|
|
42
44
|
* Instantiate once per process. Acquire before consume and release in finally after verification.
|
|
43
45
|
* Network admission precedes account-plus-network admission. There is no account-wide lockout.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type ReactNode } from 'react';
|
|
9
9
|
import type { BylineAdminServices } from './admin-services-types.js';
|
|
10
|
-
export type { AdminServiceCall, BylineAdminServices, ChangeAccountPasswordInput, CreateAdminRoleInput, CreateAdminUserInput, SetAdminUserPasswordInput, SetRoleAbilitiesInput, SetUserRolesInput, SignInInput, SignInResult, UpdateAccountInput, UpdateAdminRoleInput, UpdateAdminUserInput, WhoHasAbilityInput, } from './admin-services-types.js';
|
|
10
|
+
export type { AdminServiceCall, BylineAdminServices, ChangeAccountPasswordInput, CreateAdminRoleInput, CreateAdminUserInput, PasskeyAdminServices, PasskeyPageBinding, SetAdminUserPasswordInput, SetRoleAbilitiesInput, SetUserRolesInput, SignInInput, SignInResult, UpdateAccountInput, UpdateAdminRoleInput, UpdateAdminUserInput, WhoHasAbilityInput, } from './admin-services-types.js';
|
|
11
11
|
interface BylineAdminServicesProviderProps {
|
|
12
12
|
services: BylineAdminServices;
|
|
13
13
|
children: ReactNode;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* server fns directly today and move into `@byline/host-tanstack-start`
|
|
24
24
|
* in Phase 3 along with the route factories.
|
|
25
25
|
*/
|
|
26
|
+
import type { PasskeyAuthenticationOptions, PasskeyCeremony, PasskeyCredentialSummary, PasskeyManagementAction, PasskeyManagementGrant, PasskeyRegistrationOptions } from '@byline/auth';
|
|
26
27
|
import type { AccountResponse, ChangeAccountPasswordRequest, UpdateAccountRequest } from '../modules/admin-account/index.js';
|
|
27
28
|
import type { SetRoleAbilitiesResponse, WhoHasAbilityResponse } from '../modules/admin-permissions/index.js';
|
|
28
29
|
import type { AdminRoleResponse, UserRolesResponse } from '../modules/admin-roles/index.js';
|
|
@@ -46,6 +47,63 @@ export interface SignInInput {
|
|
|
46
47
|
* supply whatever envelope it produces without forcing a public type.
|
|
47
48
|
*/
|
|
48
49
|
export type SignInResult = unknown;
|
|
50
|
+
/** Captured before any prompt; the host checks it again before submission. */
|
|
51
|
+
export interface PasskeyPageBinding {
|
|
52
|
+
expectedSessionId: string | null;
|
|
53
|
+
assertCurrent(): void;
|
|
54
|
+
}
|
|
55
|
+
interface PasskeyBoundInput {
|
|
56
|
+
expectedSessionId: string;
|
|
57
|
+
}
|
|
58
|
+
export interface PasskeyAdminServices {
|
|
59
|
+
capability(): Promise<{
|
|
60
|
+
enabled: boolean;
|
|
61
|
+
}>;
|
|
62
|
+
captureLogin(signIn?: boolean): PasskeyPageBinding;
|
|
63
|
+
managementContext(binding: PasskeyPageBinding): Promise<{
|
|
64
|
+
id: string;
|
|
65
|
+
isSuperAdmin: boolean;
|
|
66
|
+
canManagePasskeys: boolean;
|
|
67
|
+
}>;
|
|
68
|
+
signInOptions: AdminServiceCall<Record<string, never>, PasskeyCeremony<PasskeyAuthenticationOptions>>;
|
|
69
|
+
signIn(args: {
|
|
70
|
+
data: {
|
|
71
|
+
ceremonyId: string;
|
|
72
|
+
response: unknown;
|
|
73
|
+
};
|
|
74
|
+
}, binding: PasskeyPageBinding): Promise<unknown>;
|
|
75
|
+
passwordProof: AdminServiceCall<PasskeyBoundInput & {
|
|
76
|
+
password: string;
|
|
77
|
+
action: PasskeyManagementAction;
|
|
78
|
+
}, PasskeyManagementGrant>;
|
|
79
|
+
proofOptions: AdminServiceCall<PasskeyBoundInput & {
|
|
80
|
+
action: PasskeyManagementAction;
|
|
81
|
+
}, PasskeyCeremony<PasskeyAuthenticationOptions>>;
|
|
82
|
+
proof: AdminServiceCall<PasskeyBoundInput & {
|
|
83
|
+
ceremonyId: string;
|
|
84
|
+
response: unknown;
|
|
85
|
+
}, PasskeyManagementGrant>;
|
|
86
|
+
enrollmentOptions: AdminServiceCall<PasskeyBoundInput & {
|
|
87
|
+
grantId: string;
|
|
88
|
+
}, PasskeyCeremony<PasskeyRegistrationOptions>>;
|
|
89
|
+
enrollment: AdminServiceCall<PasskeyBoundInput & {
|
|
90
|
+
ceremonyId: string;
|
|
91
|
+
response: unknown;
|
|
92
|
+
name: string;
|
|
93
|
+
}, PasskeyCredentialSummary>;
|
|
94
|
+
list: AdminServiceCall<PasskeyBoundInput & {
|
|
95
|
+
targetUserId?: string;
|
|
96
|
+
}, PasskeyCredentialSummary[]>;
|
|
97
|
+
rename: AdminServiceCall<PasskeyBoundInput & {
|
|
98
|
+
credentialRecordId: string;
|
|
99
|
+
name: string;
|
|
100
|
+
}, PasskeyCredentialSummary>;
|
|
101
|
+
remove: AdminServiceCall<PasskeyBoundInput & {
|
|
102
|
+
grantId: string;
|
|
103
|
+
}, {
|
|
104
|
+
signedOut: boolean;
|
|
105
|
+
}>;
|
|
106
|
+
}
|
|
49
107
|
/** Same shape as `UpdateAccountRequest` from `@byline/admin/admin-account`. */
|
|
50
108
|
export type UpdateAccountInput = UpdateAccountRequest;
|
|
51
109
|
/** Same shape as `ChangeAccountPasswordRequest` from `@byline/admin/admin-account`. */
|
|
@@ -119,6 +177,7 @@ export interface WhoHasAbilityInput {
|
|
|
119
177
|
}
|
|
120
178
|
export interface BylineAdminServices {
|
|
121
179
|
adminSignIn: AdminServiceCall<SignInInput, SignInResult>;
|
|
180
|
+
passkeys?: PasskeyAdminServices;
|
|
122
181
|
updateAccount: AdminServiceCall<UpdateAccountInput, AccountResponse>;
|
|
123
182
|
changeAccountPassword: AdminServiceCall<ChangeAccountPasswordInput, AccountResponse>;
|
|
124
183
|
setAdminLocale: AdminServiceCall<SetAdminLocaleInput, SetAdminLocaleResult>;
|
|
@@ -143,3 +202,4 @@ export interface BylineAdminServices {
|
|
|
143
202
|
*/
|
|
144
203
|
getCollectionDocumentVersion: (collection: string, documentId: string, versionId: string, locale: string | undefined) => Promise<Record<string, unknown>>;
|
|
145
204
|
}
|
|
205
|
+
export {};
|
package/dist/store.d.ts
CHANGED
|
@@ -9,7 +9,9 @@ import type { AdminPermissionsRepository } from './modules/admin-permissions/rep
|
|
|
9
9
|
import type { AdminPreferencesRepository } from './modules/admin-preferences/repository.js';
|
|
10
10
|
import type { AdminRolesRepository } from './modules/admin-roles/repository.js';
|
|
11
11
|
import type { AdminUsersRepository, AdminUserWithPasswordRow } from './modules/admin-users/repository.js';
|
|
12
|
+
import type { AuthMaintenanceRepository } from './modules/auth/auth-maintenance-repository.js';
|
|
12
13
|
import type { LoginSessionsRepository } from './modules/auth/login-sessions-repository.js';
|
|
14
|
+
import type { PasskeysRepository } from './modules/auth/passkeys-repository.js';
|
|
13
15
|
import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repository.js';
|
|
14
16
|
import type { SignInRateLimitStore } from './modules/auth/sign-in-rate-limiter.js';
|
|
15
17
|
/**
|
|
@@ -35,6 +37,8 @@ export interface AdminStore {
|
|
|
35
37
|
withSessionLock<T>(adminUserId: string, work: (store: AdminStore, user: AdminUserWithPasswordRow | null) => Promise<T>): Promise<T>;
|
|
36
38
|
/** Lock distinct account IDs in sorted order in one transaction for account-switch sign-in. */
|
|
37
39
|
withSessionLocks<T>(adminUserIds: string[], work: (store: AdminStore) => Promise<T>): Promise<T>;
|
|
40
|
+
authMaintenance: AuthMaintenanceRepository;
|
|
41
|
+
passkeys: PasskeysRepository;
|
|
38
42
|
loginSessions: LoginSessionsRepository;
|
|
39
43
|
signInRateLimits: SignInRateLimitStore;
|
|
40
44
|
adminUsers: AdminUsersRepository;
|
package/package.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "6.
|
|
5
|
+
"version": "6.9.0",
|
|
6
6
|
"engines": {
|
|
7
|
-
"node": ">=
|
|
7
|
+
"node": ">=22.22.2"
|
|
8
8
|
},
|
|
9
9
|
"description": "Byline CMS admin subsystem — admin users, roles, permissions, account self-service, and the built-in JWT session provider",
|
|
10
10
|
"keywords": [
|
|
@@ -157,19 +157,24 @@
|
|
|
157
157
|
"import": "./dist/modules/auth/components/sign-in-form.js",
|
|
158
158
|
"require": "./dist/modules/auth/components/sign-in-form.js"
|
|
159
159
|
},
|
|
160
|
-
"./package.json": "./package.json"
|
|
160
|
+
"./package.json": "./package.json",
|
|
161
|
+
"./auth/components/passkey-manager": {
|
|
162
|
+
"types": "./dist/modules/auth/components/passkey-manager.d.ts",
|
|
163
|
+
"import": "./dist/modules/auth/components/passkey-manager.js",
|
|
164
|
+
"require": "./dist/modules/auth/components/passkey-manager.js"
|
|
165
|
+
}
|
|
161
166
|
},
|
|
162
167
|
"files": [
|
|
163
168
|
"dist/",
|
|
164
169
|
"src/"
|
|
165
170
|
],
|
|
166
171
|
"dependencies": {
|
|
167
|
-
"@byline/analytics": "6.
|
|
168
|
-
"@byline/analytics-agent": "6.
|
|
169
|
-
"@byline/auth": "6.
|
|
170
|
-
"@byline/core": "6.
|
|
171
|
-
"@byline/i18n": "6.
|
|
172
|
-
"@byline/ui": "6.
|
|
172
|
+
"@byline/analytics": "6.9.0",
|
|
173
|
+
"@byline/analytics-agent": "6.9.0",
|
|
174
|
+
"@byline/auth": "6.9.0",
|
|
175
|
+
"@byline/core": "6.9.0",
|
|
176
|
+
"@byline/i18n": "6.9.0",
|
|
177
|
+
"@byline/ui": "6.9.0",
|
|
173
178
|
"@codemirror/commands": "^6.11.1",
|
|
174
179
|
"@codemirror/lang-css": "^6.3.1",
|
|
175
180
|
"@codemirror/lang-html": "^6.4.12",
|
|
@@ -183,6 +188,8 @@
|
|
|
183
188
|
"@codemirror/state": "^6.7.6",
|
|
184
189
|
"@codemirror/view": "^6.43.14",
|
|
185
190
|
"@lezer/highlight": "^1.2.5",
|
|
191
|
+
"@simplewebauthn/browser": "^14.0.0",
|
|
192
|
+
"@simplewebauthn/server": "^14.0.3",
|
|
186
193
|
"@tanstack/react-form-start": "^1.33.5",
|
|
187
194
|
"clsx": "^2.1.1",
|
|
188
195
|
"jose": "^6.2.12",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* .byline-field-blocks — outer wrapper
|
|
6
6
|
* .byline-field-blocks-title — block-list label heading
|
|
7
7
|
* .byline-field-blocks-stack — vertical stack of blocks + add row
|
|
8
|
+
* .byline-field-blocks-unavailable — display-only unavailable-block warning
|
|
8
9
|
* .byline-field-blocks-add-row — add-button row at the bottom
|
|
9
10
|
* .byline-field-blocks-add-label — clickable text label in the add row
|
|
10
11
|
* .byline-field-blocks-modal-head — picker modal header
|
|
@@ -33,6 +34,28 @@
|
|
|
33
34
|
gap: var(--spacing-16);
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
.unavailable,
|
|
38
|
+
:global(.byline-field-blocks-unavailable) {
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: flex-start;
|
|
41
|
+
gap: var(--spacing-8);
|
|
42
|
+
padding: var(--spacing-16);
|
|
43
|
+
border: 1px solid var(--stroke-warning);
|
|
44
|
+
border-radius: var(--border-radius-md);
|
|
45
|
+
background-color: oklch(from var(--fill-warning-weak) l c h / calc(alpha * 0.5));
|
|
46
|
+
color: var(--foreground);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.unavailable > :first-child,
|
|
50
|
+
:global(.byline-field-blocks-unavailable) > :first-child {
|
|
51
|
+
flex-shrink: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.unavailable p,
|
|
55
|
+
:global(.byline-field-blocks-unavailable) p {
|
|
56
|
+
margin: var(--spacing-4) 0 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
.add-row,
|
|
37
60
|
:global(.byline-field-blocks-add-row) {
|
|
38
61
|
display: flex;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
9
|
+
import { Fragment, useEffect, useMemo, useState } from 'react'
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
BlockAdminConfig,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
Modal,
|
|
26
26
|
moveItem,
|
|
27
27
|
PlusIcon,
|
|
28
|
+
WarningIcon,
|
|
28
29
|
} from '@byline/ui/react'
|
|
29
30
|
import cx from 'clsx'
|
|
30
31
|
|
|
@@ -35,6 +36,7 @@ import { useFieldError, useFormContext } from '../../forms/form-context'
|
|
|
35
36
|
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
36
37
|
import { hasExistingIdTargets } from '../../forms/nested-path'
|
|
37
38
|
import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
|
|
39
|
+
import { useUnavailableBlocks } from '../../forms/unavailable-blocks'
|
|
38
40
|
import styles from './blocks-field.module.css'
|
|
39
41
|
|
|
40
42
|
// ---------------------------------------------------------------------------
|
|
@@ -61,6 +63,7 @@ export const BlocksField = ({
|
|
|
61
63
|
const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
|
|
62
64
|
useFormContext()
|
|
63
65
|
const { t } = useTranslation('byline-admin')
|
|
66
|
+
const unavailableBlocks = useUnavailableBlocks(path)
|
|
64
67
|
const fieldError = useFieldError(path)
|
|
65
68
|
const htmlId = useScopedDomId(path)
|
|
66
69
|
const [items, setItems] = useState<{ id: string; data: any }[]>([])
|
|
@@ -288,7 +291,28 @@ export const BlocksField = ({
|
|
|
288
291
|
onDragEnd={handleDragEnd}
|
|
289
292
|
className={cx('byline-field-blocks-stack', styles.stack)}
|
|
290
293
|
>
|
|
291
|
-
{items.
|
|
294
|
+
{Array.from({ length: items.length + 1 }, (_, index) => (
|
|
295
|
+
<Fragment key={items[index]?.id ?? 'end'}>
|
|
296
|
+
{unavailableBlocks.map((issue, removedBefore) =>
|
|
297
|
+
Math.min(issue.storedIndex - removedBefore, items.length) === index ? (
|
|
298
|
+
<div
|
|
299
|
+
key={`${issue.storedIndex}-${issue.blockType}`}
|
|
300
|
+
role="note"
|
|
301
|
+
className={cx('byline-field-blocks-unavailable', styles.unavailable)}
|
|
302
|
+
>
|
|
303
|
+
<WarningIcon width="24px" height="24px" />
|
|
304
|
+
<div>
|
|
305
|
+
<strong>
|
|
306
|
+
{t('fields.blocks.unavailableTitle', { blockType: issue.blockType })}
|
|
307
|
+
</strong>
|
|
308
|
+
<p>{t('fields.blocks.unavailableBody')}</p>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
) : null
|
|
312
|
+
)}
|
|
313
|
+
{index < items.length && renderItem(items[index]!, index)}
|
|
314
|
+
</Fragment>
|
|
315
|
+
))}
|
|
292
316
|
<div className={cx('byline-field-blocks-add-row', styles['add-row'])}>
|
|
293
317
|
<IconButton
|
|
294
318
|
onClick={() => {
|
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
|
|
20
20
|
import type { ReactNode, RefObject } from 'react'
|
|
21
21
|
|
|
22
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
SlugifierFn,
|
|
24
|
+
StructuralMutationReceipt,
|
|
25
|
+
UnavailableBlock,
|
|
26
|
+
WorkflowStatus,
|
|
27
|
+
} from '@byline/core'
|
|
23
28
|
import { useTranslation } from '@byline/i18n/react'
|
|
24
29
|
import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react'
|
|
25
30
|
import cx from 'clsx'
|
|
@@ -263,6 +268,7 @@ export interface FormConcurrencyNoticesProps {
|
|
|
263
268
|
onDiscardRequested: () => void
|
|
264
269
|
scheduling: UseScheduledPublicationReturn
|
|
265
270
|
restoreWarnings?: string[]
|
|
271
|
+
unavailableBlocks?: UnavailableBlock[]
|
|
266
272
|
}
|
|
267
273
|
|
|
268
274
|
export const FormConcurrencyNotices = ({
|
|
@@ -276,6 +282,7 @@ export const FormConcurrencyNotices = ({
|
|
|
276
282
|
onDiscardRequested,
|
|
277
283
|
scheduling,
|
|
278
284
|
restoreWarnings,
|
|
285
|
+
unavailableBlocks,
|
|
279
286
|
}: FormConcurrencyNoticesProps): ReactNode => {
|
|
280
287
|
const { t } = useTranslation('byline-admin')
|
|
281
288
|
|
|
@@ -329,6 +336,17 @@ export const FormConcurrencyNotices = ({
|
|
|
329
336
|
onCancel={scheduling.cancel}
|
|
330
337
|
/>
|
|
331
338
|
{scheduling.modal}
|
|
339
|
+
{unavailableBlocks && unavailableBlocks.length > 0 && (
|
|
340
|
+
<Alert
|
|
341
|
+
className="m-0 mt-4"
|
|
342
|
+
intent="warning"
|
|
343
|
+
icon={true}
|
|
344
|
+
close={false}
|
|
345
|
+
title={t('forms.unavailableBlocks.title')}
|
|
346
|
+
>
|
|
347
|
+
<p>{t('forms.unavailableBlocks.body', { count: unavailableBlocks.length })}</p>
|
|
348
|
+
</Alert>
|
|
349
|
+
)}
|
|
332
350
|
{restoreWarnings && restoreWarnings.length > 0 && (
|
|
333
351
|
<Alert
|
|
334
352
|
className="m-0 mt-4"
|
|
@@ -131,7 +131,7 @@ describe('FormRenderer capabilities', () => {
|
|
|
131
131
|
})
|
|
132
132
|
|
|
133
133
|
it('forwards adminConfig.lockPath to the path widget', () => {
|
|
134
|
-
render({ ...baseProps, adminConfig: {
|
|
134
|
+
render({ ...baseProps, adminConfig: { path: 'pages', lockPath: true } })
|
|
135
135
|
|
|
136
136
|
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
137
137
|
expect(input?.readOnly).toBe(true)
|
|
@@ -139,7 +139,7 @@ describe('FormRenderer capabilities', () => {
|
|
|
139
139
|
})
|
|
140
140
|
|
|
141
141
|
it('leaves the path widget editable when no lockPath is declared', () => {
|
|
142
|
-
render({ ...baseProps, adminConfig: {
|
|
142
|
+
render({ ...baseProps, adminConfig: { path: 'pages' } })
|
|
143
143
|
|
|
144
144
|
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
145
145
|
expect(input?.readOnly).toBe(false)
|
|
@@ -156,7 +156,7 @@ describe('FormRenderer capabilities', () => {
|
|
|
156
156
|
fields: [{ name: 'ref', label: 'Ref', type: 'counter' as const }],
|
|
157
157
|
useAsPath: 'ref',
|
|
158
158
|
initialData: { id: 'doc-1', path: 'about-us', fields: { ref: 448 } },
|
|
159
|
-
adminConfig: {
|
|
159
|
+
adminConfig: { path: 'pages' },
|
|
160
160
|
})
|
|
161
161
|
|
|
162
162
|
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
@@ -173,7 +173,7 @@ describe('FormRenderer capabilities', () => {
|
|
|
173
173
|
mode: 'create' as const,
|
|
174
174
|
useAsPath: undefined,
|
|
175
175
|
initialData: { fields: {} },
|
|
176
|
-
adminConfig: {
|
|
176
|
+
adminConfig: { path: 'pages', lockPath: true },
|
|
177
177
|
})
|
|
178
178
|
|
|
179
179
|
expect(container.querySelector('.byline-form-path')).not.toBeNull()
|
|
@@ -188,14 +188,14 @@ describe('FormRenderer capabilities', () => {
|
|
|
188
188
|
mode: 'create' as const,
|
|
189
189
|
useAsPath: undefined,
|
|
190
190
|
initialData: { fields: {} },
|
|
191
|
-
adminConfig: {
|
|
191
|
+
adminConfig: { path: 'pages' },
|
|
192
192
|
})
|
|
193
193
|
|
|
194
194
|
expect(container.querySelector('.byline-form-path')).toBeNull()
|
|
195
195
|
})
|
|
196
196
|
|
|
197
197
|
it('lets showPath: false override lockPath', () => {
|
|
198
|
-
render({ ...baseProps, showPath: false, adminConfig: {
|
|
198
|
+
render({ ...baseProps, showPath: false, adminConfig: { path: 'pages', lockPath: true } })
|
|
199
199
|
|
|
200
200
|
expect(container.querySelector('.byline-form-path')).toBeNull()
|
|
201
201
|
})
|