@byline/admin 6.7.2 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/fields/blocks/blocks-field.js +33 -3
  2. package/dist/fields/blocks/blocks-field.module.js +1 -0
  3. package/dist/fields/blocks/blocks-field_module.css +19 -0
  4. package/dist/forms/form-page-chrome.d.ts +3 -2
  5. package/dist/forms/form-page-chrome.js +13 -1
  6. package/dist/forms/form-renderer.d.ts +2 -1
  7. package/dist/forms/form-renderer.js +23 -17
  8. package/dist/forms/unavailable-blocks.d.ts +15 -0
  9. package/dist/forms/unavailable-blocks.js +38 -0
  10. package/dist/modules/admin-account/components/change-password.js +1 -1
  11. package/dist/modules/admin-account/components/container.js +18 -3
  12. package/dist/modules/admin-account/components/container.module.js +2 -0
  13. package/dist/modules/admin-account/components/container_module.css +7 -0
  14. package/dist/modules/admin-account/components/preferences.js +1 -1
  15. package/dist/modules/admin-account/components/update.js +1 -1
  16. package/dist/modules/admin-roles/components/create.js +1 -1
  17. package/dist/modules/admin-roles/components/permissions.js +1 -1
  18. package/dist/modules/admin-roles/components/update.js +1 -1
  19. package/dist/modules/admin-users/abilities.d.ts +1 -0
  20. package/dist/modules/admin-users/abilities.js +8 -1
  21. package/dist/modules/admin-users/components/create.js +1 -1
  22. package/dist/modules/admin-users/components/roles.js +1 -1
  23. package/dist/modules/admin-users/components/set-password.js +1 -1
  24. package/dist/modules/admin-users/components/update.js +1 -1
  25. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  26. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  27. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  28. package/dist/modules/auth/auth-maintenance.js +39 -0
  29. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  30. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  31. package/dist/modules/auth/components/passkey-browser.js +118 -0
  32. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  33. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  34. package/dist/modules/auth/components/passkey-manager.js +406 -0
  35. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  36. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  37. package/dist/modules/auth/components/sign-in-form.js +62 -3
  38. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  39. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  40. package/dist/modules/auth/index.d.ts +5 -0
  41. package/dist/modules/auth/index.js +3 -0
  42. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  43. package/dist/modules/auth/jwt-session-provider.js +135 -94
  44. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  45. package/dist/modules/auth/native-passkey-provider.js +618 -0
  46. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  47. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  48. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  49. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  50. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  51. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  52. package/dist/modules/auth/passkeys-repository.js +1 -0
  53. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  54. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  55. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  56. package/dist/services/admin-services-context.d.ts +1 -1
  57. package/dist/services/admin-services-types.d.ts +60 -0
  58. package/dist/store.d.ts +4 -0
  59. package/package.json +16 -9
  60. package/src/fields/blocks/blocks-field.module.css +23 -0
  61. package/src/fields/blocks/blocks-field.tsx +26 -2
  62. package/src/forms/form-page-chrome.tsx +19 -1
  63. package/src/forms/form-renderer-capabilities.test.tsx +6 -6
  64. package/src/forms/form-renderer.tsx +24 -15
  65. package/src/forms/unavailable-blocks.test.tsx +152 -0
  66. package/src/forms/unavailable-blocks.tsx +58 -0
  67. package/src/modules/admin-account/components/change-password.tsx +1 -1
  68. package/src/modules/admin-account/components/container.module.css +9 -0
  69. package/src/modules/admin-account/components/container.tsx +12 -3
  70. package/src/modules/admin-account/components/preferences.tsx +1 -1
  71. package/src/modules/admin-account/components/update.tsx +1 -1
  72. package/src/modules/admin-roles/components/create.tsx +1 -1
  73. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  74. package/src/modules/admin-roles/components/update.tsx +1 -1
  75. package/src/modules/admin-users/abilities.ts +7 -0
  76. package/src/modules/admin-users/components/create.tsx +1 -1
  77. package/src/modules/admin-users/components/roles.tsx +1 -1
  78. package/src/modules/admin-users/components/set-password.tsx +1 -1
  79. package/src/modules/admin-users/components/update.tsx +1 -1
  80. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  81. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  82. package/src/modules/auth/auth-maintenance.ts +62 -0
  83. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  84. package/src/modules/auth/components/passkey-browser.ts +139 -0
  85. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  86. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  87. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  88. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  89. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  90. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  91. package/src/modules/auth/index.ts +15 -0
  92. package/src/modules/auth/jwt-session-provider.ts +116 -44
  93. package/src/modules/auth/native-passkey-provider.ts +710 -0
  94. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  95. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  96. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  97. package/src/modules/auth/passkeys-repository.ts +97 -0
  98. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  99. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  100. package/src/services/admin-services-context.tsx +2 -0
  101. package/src/services/admin-services-types.ts +60 -0
  102. package/src/store.ts +4 -0
@@ -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
- /** Remove rows whose `expires_at` is in the past. Housekeeping. */
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.
@@ -226,4 +226,4 @@ function createPasswordSignInLimiter(store, secret, policy = DEFAULT_POLICY, now
226
226
  }
227
227
  };
228
228
  }
229
- export { createPasswordSignInLimiter };
229
+ export { createPasswordSignInLimiter, signInNetwork };
@@ -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.7.2",
5
+ "version": "6.9.0",
6
6
  "engines": {
7
- "node": ">=20.9.0"
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.7.2",
168
- "@byline/analytics-agent": "6.7.2",
169
- "@byline/auth": "6.7.2",
170
- "@byline/core": "6.7.2",
171
- "@byline/i18n": "6.7.2",
172
- "@byline/ui": "6.7.2",
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.map((item, index) => renderItem(item, index))}
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 { SlugifierFn, StructuralMutationReceipt, WorkflowStatus } from '@byline/core'
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: { slug: 'pages', lockPath: true } })
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: { slug: 'pages' } })
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: { slug: 'pages' },
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: { slug: 'pages', lockPath: true },
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: { slug: 'pages' },
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: { slug: 'pages', lockPath: true } })
198
+ render({ ...baseProps, showPath: false, adminConfig: { path: 'pages', lockPath: true } })
199
199
 
200
200
  expect(container.querySelector('.byline-form-path')).toBeNull()
201
201
  })