@byline/admin 6.7.2 → 6.8.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 (86) hide show
  1. package/dist/modules/admin-account/components/change-password.js +1 -1
  2. package/dist/modules/admin-account/components/container.js +18 -3
  3. package/dist/modules/admin-account/components/container.module.js +2 -0
  4. package/dist/modules/admin-account/components/container_module.css +7 -0
  5. package/dist/modules/admin-account/components/preferences.js +1 -1
  6. package/dist/modules/admin-account/components/update.js +1 -1
  7. package/dist/modules/admin-roles/components/create.js +1 -1
  8. package/dist/modules/admin-roles/components/permissions.js +1 -1
  9. package/dist/modules/admin-roles/components/update.js +1 -1
  10. package/dist/modules/admin-users/abilities.d.ts +1 -0
  11. package/dist/modules/admin-users/abilities.js +8 -1
  12. package/dist/modules/admin-users/components/create.js +1 -1
  13. package/dist/modules/admin-users/components/roles.js +1 -1
  14. package/dist/modules/admin-users/components/set-password.js +1 -1
  15. package/dist/modules/admin-users/components/update.js +1 -1
  16. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  17. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  18. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  19. package/dist/modules/auth/auth-maintenance.js +39 -0
  20. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  21. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  22. package/dist/modules/auth/components/passkey-browser.js +118 -0
  23. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  24. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  25. package/dist/modules/auth/components/passkey-manager.js +406 -0
  26. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  27. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  28. package/dist/modules/auth/components/sign-in-form.js +62 -3
  29. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  30. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  31. package/dist/modules/auth/index.d.ts +5 -0
  32. package/dist/modules/auth/index.js +3 -0
  33. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  34. package/dist/modules/auth/jwt-session-provider.js +135 -94
  35. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  36. package/dist/modules/auth/native-passkey-provider.js +618 -0
  37. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  38. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  39. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  40. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  41. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  42. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  43. package/dist/modules/auth/passkeys-repository.js +1 -0
  44. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  45. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  46. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  47. package/dist/services/admin-services-context.d.ts +1 -1
  48. package/dist/services/admin-services-types.d.ts +60 -0
  49. package/dist/store.d.ts +4 -0
  50. package/package.json +16 -9
  51. package/src/modules/admin-account/components/change-password.tsx +1 -1
  52. package/src/modules/admin-account/components/container.module.css +9 -0
  53. package/src/modules/admin-account/components/container.tsx +12 -3
  54. package/src/modules/admin-account/components/preferences.tsx +1 -1
  55. package/src/modules/admin-account/components/update.tsx +1 -1
  56. package/src/modules/admin-roles/components/create.tsx +1 -1
  57. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  58. package/src/modules/admin-roles/components/update.tsx +1 -1
  59. package/src/modules/admin-users/abilities.ts +7 -0
  60. package/src/modules/admin-users/components/create.tsx +1 -1
  61. package/src/modules/admin-users/components/roles.tsx +1 -1
  62. package/src/modules/admin-users/components/set-password.tsx +1 -1
  63. package/src/modules/admin-users/components/update.tsx +1 -1
  64. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  65. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  66. package/src/modules/auth/auth-maintenance.ts +62 -0
  67. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  68. package/src/modules/auth/components/passkey-browser.ts +139 -0
  69. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  70. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  71. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  72. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  73. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  74. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  75. package/src/modules/auth/index.ts +15 -0
  76. package/src/modules/auth/jwt-session-provider.ts +116 -44
  77. package/src/modules/auth/native-passkey-provider.ts +710 -0
  78. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  79. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  80. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  81. package/src/modules/auth/passkeys-repository.ts +97 -0
  82. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  83. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  84. package/src/services/admin-services-context.tsx +2 -0
  85. package/src/services/admin-services-types.ts +60 -0
  86. 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.8.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.8.0",
173
+ "@byline/analytics-agent": "6.8.0",
174
+ "@byline/auth": "6.8.0",
175
+ "@byline/core": "6.8.0",
176
+ "@byline/i18n": "6.8.0",
177
+ "@byline/ui": "6.8.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",
@@ -215,7 +215,7 @@ export function ChangeAccountPassword({ account, onClose, onSuccess }: ChangePas
215
215
  <div className={cx('byline-account-change-password-actions', styles.actions)}>
216
216
  <Button
217
217
  type="button"
218
- intent="secondary"
218
+ intent="noeffect"
219
219
  size="sm"
220
220
  onClick={onClose}
221
221
  className={cx('byline-account-change-password-action', styles.action)}
@@ -13,6 +13,7 @@
13
13
  * .byline-account-status-on
14
14
  * .byline-account-status-off
15
15
  * .byline-account-status-help — italic helper line on Account Status card
16
+ * .byline-account-preferences-theme — colour theme block inside Preferences
16
17
  * .byline-account-drawer — drawer responsive width override
17
18
  * .byline-account-drawer-body — drawer content container
18
19
  * .byline-account-drawer-scroll — drawer scrollable inner viewport
@@ -92,6 +93,14 @@
92
93
  font-size: var(--font-size-xs);
93
94
  }
94
95
 
96
+ .preferences-theme,
97
+ :global(.byline-account-preferences-theme) {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: var(--spacing-8);
101
+ margin-top: var(--spacing-20);
102
+ }
103
+
95
104
  /*
96
105
  * Italic helper for "Not set" placeholders. Replaces the Tailwind
97
106
  * `italic` utility we used pre-lift; with the component now in a
@@ -32,6 +32,7 @@ import { useTranslation } from '@byline/i18n/react'
32
32
  import { CloseIcon, Drawer, EditIcon, IconButton } from '@byline/ui/react'
33
33
  import cx from 'clsx'
34
34
 
35
+ import { PasskeyManager } from '../../auth/components/passkey-manager.js'
35
36
  import { ChangeAccountPassword } from './change-password.js'
36
37
  import styles from './container.module.css'
37
38
  import { Preferences } from './preferences.js'
@@ -180,6 +181,12 @@ export function AccountSelfContainer({ account }: AccountSelfContainerProps) {
180
181
  <p className={cx('muted', 'byline-account-status-help', styles['status-help'])}>
181
182
  {t('account.preferences.help')}
182
183
  </p>
184
+ <div className={cx('byline-account-preferences-theme', styles['preferences-theme'])}>
185
+ <p className={cx('byline-account-line', styles.line)}>
186
+ <span className="muted">{t('account.appearance.colorTheme')}</span>
187
+ </p>
188
+ <ThemeSwitch />
189
+ </div>
183
190
  </ContainerSection>
184
191
  </div>
185
192
 
@@ -192,9 +199,11 @@ export function AccountSelfContainer({ account }: AccountSelfContainerProps) {
192
199
  <p className={cx('byline-account-line', styles.line)}>{t('account.password.intro')}</p>
193
200
  </ContainerSection>
194
201
 
195
- <ContainerSection title={t('account.sections.appearance')}>
196
- <ThemeSwitch />
197
- </ContainerSection>
202
+ <PasskeyManager
203
+ targetUserId={currentAccount.id}
204
+ targetIsSuperAdmin={currentAccount.is_super_admin}
205
+ section={ContainerSection}
206
+ />
198
207
 
199
208
  <ContainerSection title={t('account.sections.status')}>
200
209
  <p className={cx('byline-account-line', styles.line)}>
@@ -172,7 +172,7 @@ export function Preferences({ account, onClose, onSuccess }: PreferencesProps) {
172
172
  <div className={cx('byline-account-preferences-actions', styles.actions)}>
173
173
  <Button
174
174
  type="button"
175
- intent="secondary"
175
+ intent="noeffect"
176
176
  size="sm"
177
177
  onClick={onClose}
178
178
  className={cx('byline-account-preferences-action', styles.action)}
@@ -240,7 +240,7 @@ export function UpdateAccount({ account, onClose, onSuccess }: UpdateAccountProp
240
240
  <div className={cx('byline-account-update-actions', styles.actions)}>
241
241
  <Button
242
242
  type="button"
243
- intent="secondary"
243
+ intent="noeffect"
244
244
  size="sm"
245
245
  onClick={onClose}
246
246
  className={cx('byline-account-update-action', styles.action)}
@@ -192,7 +192,7 @@ export function CreateAdminRole({ onClose, onSuccess }: CreateAdminRoleProps) {
192
192
  <div className={cx('byline-role-create-actions', styles.actions)}>
193
193
  <Button
194
194
  type="button"
195
- intent="secondary"
195
+ intent="noeffect"
196
196
  size="sm"
197
197
  onClick={onClose}
198
198
  className={cx('byline-role-create-action', styles.action)}
@@ -288,7 +288,7 @@ export function RolePermissions({
288
288
  <div className={cx('byline-role-permissions-actions', styles.actions)}>
289
289
  <Button
290
290
  type="button"
291
- intent="secondary"
291
+ intent="noeffect"
292
292
  size="xs"
293
293
  onClick={handleCancel}
294
294
  disabled={saving}
@@ -188,7 +188,7 @@ export function UpdateRole({ role, onClose, onSuccess }: UpdateRoleProps) {
188
188
  <div className={cx('byline-role-update-actions', styles.actions)}>
189
189
  <Button
190
190
  type="button"
191
- intent="secondary"
191
+ intent="noeffect"
192
192
  size="sm"
193
193
  onClick={onClose}
194
194
  className={cx('byline-role-update-action', styles.action)}
@@ -32,6 +32,7 @@ export const ADMIN_USERS_ABILITIES = {
32
32
  update: 'admin.users.update',
33
33
  delete: 'admin.users.delete',
34
34
  changePassword: 'admin.users.changePassword',
35
+ managePasskeys: 'admin.users.managePasskeys',
35
36
  } as const
36
37
 
37
38
  export type AdminUsersAbilityKey =
@@ -43,6 +44,12 @@ export type AdminUsersAbilityKey =
43
44
  * the webapp wires into `initBylineCore()`.
44
45
  */
45
46
  export function registerAdminUsersAbilities(registry: AbilityRegistry): void {
47
+ registry.register({
48
+ key: ADMIN_USERS_ABILITIES.managePasskeys,
49
+ label: 'Manage admin user passkeys',
50
+ group: 'admin.users',
51
+ source: 'admin',
52
+ })
46
53
  registry.register({
47
54
  key: ADMIN_USERS_ABILITIES.read,
48
55
  label: 'Read admin users',
@@ -299,7 +299,7 @@ export function CreateAdminUser({ onClose, onSuccess }: CreateAdminUserProps) {
299
299
  <div className={cx('byline-user-create-actions', styles.actions)}>
300
300
  <Button
301
301
  type="button"
302
- intent="secondary"
302
+ intent="noeffect"
303
303
  size="sm"
304
304
  onClick={onClose}
305
305
  className={cx('byline-user-create-action', styles.action)}
@@ -143,7 +143,7 @@ export function UserRoles({ user, allRoles, initialRoleIds, onClose, onSaved }:
143
143
  <div className={cx('byline-user-roles-actions', styles.actions)}>
144
144
  <Button
145
145
  type="button"
146
- intent="secondary"
146
+ intent="noeffect"
147
147
  size="xs"
148
148
  onClick={onClose}
149
149
  disabled={saving}
@@ -160,7 +160,7 @@ export function SetPassword({ user, onClose, onSuccess }: SetPasswordProps) {
160
160
  <div className={cx('byline-user-set-password-actions', styles.actions)}>
161
161
  <Button
162
162
  type="button"
163
- intent="secondary"
163
+ intent="noeffect"
164
164
  size="sm"
165
165
  onClick={onClose}
166
166
  className={cx('byline-user-set-password-action', styles.action)}
@@ -295,7 +295,7 @@ export function UpdateUser({ user, onClose, onSuccess }: UpdateUserProps) {
295
295
  <div className={cx('byline-user-update-actions', styles.actions)}>
296
296
  <Button
297
297
  type="button"
298
- intent="secondary"
298
+ intent="noeffect"
299
299
  size="sm"
300
300
  onClick={onClose}
301
301
  className={cx('byline-user-update-action', styles.action)}