@byline/host-tanstack-start 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.
- package/dist/admin-shell/admin-roles/container_module.css +1 -1
- package/dist/admin-shell/admin-roles/delete.js +1 -1
- package/dist/admin-shell/admin-users/container.js +181 -159
- package/dist/admin-shell/admin-users/container.module.js +2 -1
- package/dist/admin-shell/admin-users/container_module.css +5 -1
- package/dist/admin-shell/admin-users/delete.js +1 -1
- package/dist/integrations/byline-admin-services.js +2 -0
- package/dist/integrations/passkey-admin-services.d.ts +9 -0
- package/dist/integrations/passkey-admin-services.js +53 -0
- package/dist/integrations/session-coordination.d.ts +3 -1
- package/dist/integrations/session-coordination.js +3 -1
- package/dist/integrations/session-middleware.d.ts +3 -1
- package/dist/integrations/session-middleware.js +87 -79
- package/dist/integrations/sign-in-body.d.ts +3 -0
- package/dist/integrations/sign-in-body.js +19 -4
- package/dist/integrations/sign-in-middleware.js +30 -5
- package/dist/server-fns/auth/passkey-admission.d.ts +24 -0
- package/dist/server-fns/auth/passkey-admission.js +88 -0
- package/dist/server-fns/auth/passkeys.d.ts +96 -0
- package/dist/server-fns/auth/passkeys.js +238 -0
- package/dist/server-fns/auth/reconcile-locale.d.ts +20 -0
- package/dist/server-fns/auth/reconcile-locale.js +16 -0
- package/dist/server-fns/auth/sign-in.js +1 -15
- package/package.json +11 -11
- package/src/admin-shell/admin-roles/container.module.css +1 -1
- package/src/admin-shell/admin-roles/delete.tsx +1 -1
- package/src/admin-shell/admin-users/container.module.css +7 -1
- package/src/admin-shell/admin-users/container.tsx +22 -2
- package/src/admin-shell/admin-users/delete.tsx +1 -1
- package/src/integrations/byline-admin-services.ts +3 -0
- package/src/integrations/passkey-admin-services.ts +85 -0
- package/src/integrations/session-client.test.node.ts +73 -0
- package/src/integrations/session-coordination.ts +3 -1
- package/src/integrations/session-middleware.ts +86 -72
- package/src/integrations/sign-in-body.test.node.ts +31 -1
- package/src/integrations/sign-in-body.ts +27 -3
- package/src/integrations/sign-in-middleware.ts +42 -9
- package/src/server-fns/auth/passkey-admission.test.node.ts +173 -0
- package/src/server-fns/auth/passkey-admission.ts +125 -0
- package/src/server-fns/auth/passkeys.test.node.ts +247 -0
- package/src/server-fns/auth/passkeys.ts +252 -0
- package/src/server-fns/auth/reconcile-locale.ts +40 -0
- package/src/server-fns/auth/sign-in.ts +1 -32
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
|
|
3
|
+
import { ERR_SESSION_CHANGED, ERR_UNAUTHENTICATED, isAdminAuth } from "@byline/auth";
|
|
4
|
+
import { clearPreviewCookie, clearSessionCookies, getAdminRequestContext, readAccessTokenCookie, readRefreshTokenCookie, setSessionCookies } from "@byline/client/server";
|
|
5
|
+
import { getServerConfig } from "@byline/core";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { bylineCore } from "../../integrations/byline-core.js";
|
|
8
|
+
import { adminSessionMiddleware, adminSessionMutationMiddleware } from "../../integrations/session-middleware.js";
|
|
9
|
+
import { wasPasskeyBodyChecked } from "../../integrations/sign-in-body.js";
|
|
10
|
+
import { withPasskeyAdmission } from "./passkey-admission.js";
|
|
11
|
+
import { reconcileLocaleAfterSignIn } from "./reconcile-locale.js";
|
|
12
|
+
const session = z.object({
|
|
13
|
+
expectedSessionId: z.uuid()
|
|
14
|
+
});
|
|
15
|
+
const bearer = z.string().regex(/^[A-Za-z0-9_-]{43}$/);
|
|
16
|
+
const action = z.discriminatedUnion('purpose', [
|
|
17
|
+
z.object({
|
|
18
|
+
purpose: z.literal('enroll')
|
|
19
|
+
}).strict(),
|
|
20
|
+
z.object({
|
|
21
|
+
purpose: z.literal('remove'),
|
|
22
|
+
targetUserId: z.uuid(),
|
|
23
|
+
credentialRecordId: z.uuid()
|
|
24
|
+
}).strict()
|
|
25
|
+
]);
|
|
26
|
+
const passkeys_name = z.string().trim().min(1).max(80);
|
|
27
|
+
function validate(schema) {
|
|
28
|
+
return (input)=>{
|
|
29
|
+
const parsed = schema.safeParse(input);
|
|
30
|
+
if (!parsed.success) throw new Response('Invalid passkey input', {
|
|
31
|
+
status: 400,
|
|
32
|
+
headers: {
|
|
33
|
+
'Cache-Control': 'no-store'
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return parsed.data;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function configuredProvider() {
|
|
40
|
+
setResponseHeader('Cache-Control', 'no-store');
|
|
41
|
+
const config = getServerConfig();
|
|
42
|
+
const provider = config.sessionProvider;
|
|
43
|
+
if (!config.passkeys || provider?.capabilities?.passkeys !== true || !provider.passkeys) throw new Response('Passkeys are not enabled', {
|
|
44
|
+
status: 404,
|
|
45
|
+
headers: {
|
|
46
|
+
'Cache-Control': 'no-store'
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return provider.passkeys;
|
|
50
|
+
}
|
|
51
|
+
async function currentSession(expectedSessionId) {
|
|
52
|
+
const auth = await getAdminRequestContext();
|
|
53
|
+
if (auth.sessionId !== expectedSessionId) throw ERR_SESSION_CHANGED({
|
|
54
|
+
message: 'session changed before passkey operation'
|
|
55
|
+
});
|
|
56
|
+
const accessToken = readAccessTokenCookie();
|
|
57
|
+
if (!accessToken) throw ERR_UNAUTHENTICATED({
|
|
58
|
+
message: 'access credential missing'
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
accessToken,
|
|
62
|
+
expectedSessionId
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function admitted(purpose, work, expectedSessionId) {
|
|
66
|
+
const provider = configuredProvider();
|
|
67
|
+
const config = getServerConfig();
|
|
68
|
+
const request = getRequest();
|
|
69
|
+
return withPasskeyAdmission({
|
|
70
|
+
provider,
|
|
71
|
+
purpose,
|
|
72
|
+
request,
|
|
73
|
+
bodyChecked: wasPasskeyBodyChecked(request, 'password-proof' === purpose),
|
|
74
|
+
protection: config.passkeyRequests,
|
|
75
|
+
...'password-proof' === purpose ? {
|
|
76
|
+
password: {
|
|
77
|
+
protection: config.passwordSignIn,
|
|
78
|
+
resolveActorEmail: async ()=>{
|
|
79
|
+
const auth = await getAdminRequestContext();
|
|
80
|
+
if (!auth.actor || !expectedSessionId || auth.sessionId !== expectedSessionId) throw ERR_SESSION_CHANGED({
|
|
81
|
+
message: 'session changed before password proof'
|
|
82
|
+
});
|
|
83
|
+
const user = await bylineCore().adminStore?.adminUsers.getById(auth.actor.id);
|
|
84
|
+
if (!user) throw ERR_UNAUTHENTICATED({
|
|
85
|
+
message: 'account unavailable'
|
|
86
|
+
});
|
|
87
|
+
return user.email;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} : {},
|
|
91
|
+
work: ({ ip })=>work(provider, {
|
|
92
|
+
ip,
|
|
93
|
+
userAgent: request.headers.get('user-agent')?.slice(0, 512)
|
|
94
|
+
})
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const getPasskeyCapability = createServerFn({
|
|
98
|
+
method: 'GET'
|
|
99
|
+
}).handler(async ()=>{
|
|
100
|
+
setResponseHeader('Cache-Control', 'no-store');
|
|
101
|
+
const config = getServerConfig();
|
|
102
|
+
return {
|
|
103
|
+
enabled: !!(config.passkeys && config.sessionProvider?.capabilities?.passkeys && config.sessionProvider.passkeys)
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
const passkeySignInOptions = createServerFn({
|
|
107
|
+
method: 'POST'
|
|
108
|
+
}).validator(validate(z.object({}).strict())).handler(async ()=>admitted('sign-in-options', (provider, metadata)=>provider.signInOptions(metadata)));
|
|
109
|
+
const passkeySignIn = createServerFn({
|
|
110
|
+
method: 'POST'
|
|
111
|
+
}).validator(validate(z.object({
|
|
112
|
+
ceremonyId: bearer,
|
|
113
|
+
response: z.unknown()
|
|
114
|
+
}).strict())).handler(async ({ data })=>admitted('sign-in', async (provider, metadata)=>{
|
|
115
|
+
const result = await provider.verifySignIn({
|
|
116
|
+
...data,
|
|
117
|
+
...metadata,
|
|
118
|
+
previousAccessToken: readAccessTokenCookie(),
|
|
119
|
+
previousRefreshToken: readRefreshTokenCookie()
|
|
120
|
+
});
|
|
121
|
+
setSessionCookies(result);
|
|
122
|
+
try {
|
|
123
|
+
await reconcileLocaleAfterSignIn(result.actor.id);
|
|
124
|
+
} catch {}
|
|
125
|
+
return {
|
|
126
|
+
userId: result.actor.id,
|
|
127
|
+
sessionId: result.sessionId
|
|
128
|
+
};
|
|
129
|
+
}));
|
|
130
|
+
const passkeyPasswordProof = createServerFn({
|
|
131
|
+
method: 'POST'
|
|
132
|
+
}).middleware([
|
|
133
|
+
adminSessionMiddleware
|
|
134
|
+
]).validator(validate(session.extend({
|
|
135
|
+
password: z.string().min(1).max(128),
|
|
136
|
+
action: action
|
|
137
|
+
}).strict())).handler(async ({ data })=>admitted('password-proof', async (provider)=>provider.passwordProof({
|
|
138
|
+
...data,
|
|
139
|
+
...await currentSession(data.expectedSessionId)
|
|
140
|
+
}), data.expectedSessionId));
|
|
141
|
+
const passkeyProofOptions = createServerFn({
|
|
142
|
+
method: 'POST'
|
|
143
|
+
}).middleware([
|
|
144
|
+
adminSessionMiddleware
|
|
145
|
+
]).validator(validate(session.extend({
|
|
146
|
+
action: action
|
|
147
|
+
}).strict())).handler(async ({ data })=>admitted('proof-options', async (provider)=>provider.proofOptions({
|
|
148
|
+
...data,
|
|
149
|
+
...await currentSession(data.expectedSessionId)
|
|
150
|
+
})));
|
|
151
|
+
const passkeyProof = createServerFn({
|
|
152
|
+
method: 'POST'
|
|
153
|
+
}).middleware([
|
|
154
|
+
adminSessionMiddleware
|
|
155
|
+
]).validator(validate(session.extend({
|
|
156
|
+
ceremonyId: bearer,
|
|
157
|
+
response: z.unknown()
|
|
158
|
+
}).strict())).handler(async ({ data })=>admitted('proof', async (provider)=>provider.verifyProof({
|
|
159
|
+
...data,
|
|
160
|
+
...await currentSession(data.expectedSessionId)
|
|
161
|
+
})));
|
|
162
|
+
const passkeyEnrollmentOptions = createServerFn({
|
|
163
|
+
method: 'POST'
|
|
164
|
+
}).middleware([
|
|
165
|
+
adminSessionMiddleware
|
|
166
|
+
]).validator(validate(session.extend({
|
|
167
|
+
grantId: bearer
|
|
168
|
+
}).strict())).handler(async ({ data })=>admitted('enrollment-options', async (provider)=>provider.enrollmentOptions({
|
|
169
|
+
...data,
|
|
170
|
+
...await currentSession(data.expectedSessionId)
|
|
171
|
+
})));
|
|
172
|
+
const passkeyEnrollment = createServerFn({
|
|
173
|
+
method: 'POST'
|
|
174
|
+
}).middleware([
|
|
175
|
+
adminSessionMiddleware
|
|
176
|
+
]).validator(validate(session.extend({
|
|
177
|
+
ceremonyId: bearer,
|
|
178
|
+
response: z.unknown(),
|
|
179
|
+
name: passkeys_name
|
|
180
|
+
}).strict())).handler(async ({ data })=>admitted('enrollment', async (provider)=>provider.verifyEnrollment({
|
|
181
|
+
...data,
|
|
182
|
+
...await currentSession(data.expectedSessionId)
|
|
183
|
+
})));
|
|
184
|
+
const passkeyList = createServerFn({
|
|
185
|
+
method: 'POST'
|
|
186
|
+
}).middleware([
|
|
187
|
+
adminSessionMiddleware
|
|
188
|
+
]).validator(validate(session.extend({
|
|
189
|
+
targetUserId: z.uuid().optional()
|
|
190
|
+
}).strict())).handler(async ({ data })=>admitted('list', async (provider)=>provider.listCredentials({
|
|
191
|
+
...data,
|
|
192
|
+
...await currentSession(data.expectedSessionId)
|
|
193
|
+
})));
|
|
194
|
+
const passkeyRename = createServerFn({
|
|
195
|
+
method: 'POST'
|
|
196
|
+
}).middleware([
|
|
197
|
+
adminSessionMiddleware
|
|
198
|
+
]).validator(validate(session.extend({
|
|
199
|
+
credentialRecordId: z.uuid(),
|
|
200
|
+
name: passkeys_name
|
|
201
|
+
}).strict())).handler(async ({ data })=>admitted('rename', async (provider)=>provider.renameCredential({
|
|
202
|
+
...data,
|
|
203
|
+
...await currentSession(data.expectedSessionId)
|
|
204
|
+
})));
|
|
205
|
+
const passkeyRemove = createServerFn({
|
|
206
|
+
method: 'POST'
|
|
207
|
+
}).middleware([
|
|
208
|
+
adminSessionMutationMiddleware
|
|
209
|
+
]).validator(validate(session.extend({
|
|
210
|
+
grantId: bearer
|
|
211
|
+
}).strict())).handler(async ({ data })=>admitted('remove', async (provider)=>{
|
|
212
|
+
const result = await provider.removeCredential({
|
|
213
|
+
...data,
|
|
214
|
+
...await currentSession(data.expectedSessionId)
|
|
215
|
+
});
|
|
216
|
+
if (result.signedOut) {
|
|
217
|
+
clearSessionCookies();
|
|
218
|
+
clearPreviewCookie();
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}));
|
|
222
|
+
const passkeyManagementContext = createServerFn({
|
|
223
|
+
method: 'POST'
|
|
224
|
+
}).middleware([
|
|
225
|
+
adminSessionMiddleware
|
|
226
|
+
]).validator(validate(session.strict())).handler(async ({ data })=>admitted('list', async ()=>{
|
|
227
|
+
await currentSession(data.expectedSessionId);
|
|
228
|
+
const { actor } = await getAdminRequestContext();
|
|
229
|
+
if (!isAdminAuth(actor)) throw ERR_UNAUTHENTICATED({
|
|
230
|
+
message: 'admin login required'
|
|
231
|
+
});
|
|
232
|
+
return {
|
|
233
|
+
id: actor.id,
|
|
234
|
+
isSuperAdmin: actor.isSuperAdmin,
|
|
235
|
+
canManagePasskeys: actor.hasAbility('admin.users.managePasskeys')
|
|
236
|
+
};
|
|
237
|
+
}));
|
|
238
|
+
export { getPasskeyCapability, passkeyEnrollment, passkeyEnrollmentOptions, passkeyList, passkeyManagementContext, passkeyPasswordProof, passkeyProof, passkeyProofOptions, passkeyRemove, passkeyRename, passkeySignIn, passkeySignInOptions };
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Apply the `byline_admin_lng` cookie to `admin_users.preferred_locale`
|
|
10
|
+
* when the two diverge after sign-in. No-op when:
|
|
11
|
+
*
|
|
12
|
+
* - The cookie is unset (the cascade falls through to the existing
|
|
13
|
+
* column / Accept-Language / default anyway).
|
|
14
|
+
* - The cookie carries a locale outside `i18n.admin.locales`
|
|
15
|
+
* (stale value pointing at a removed locale — let the resolver
|
|
16
|
+
* fall through cleanly).
|
|
17
|
+
* - No admin store is configured (headless tooling paths).
|
|
18
|
+
* - The stored value already matches the cookie.
|
|
19
|
+
*/
|
|
20
|
+
export declare function reconcileLocaleAfterSignIn(adminUserId: string): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readAdminLocaleCookie } from "../../i18n/locale-cookie.js";
|
|
2
|
+
import { bylineCore } from "../../integrations/byline-core.js";
|
|
3
|
+
async function reconcileLocaleAfterSignIn(adminUserId) {
|
|
4
|
+
const cookieLocale = readAdminLocaleCookie();
|
|
5
|
+
if (null == cookieLocale) return;
|
|
6
|
+
const core = bylineCore();
|
|
7
|
+
const locales = core.config.i18n.admin.locales;
|
|
8
|
+
if (!locales.includes(cookieLocale)) return;
|
|
9
|
+
const adminStore = core.adminStore;
|
|
10
|
+
if (null == adminStore) return;
|
|
11
|
+
const row = await adminStore.adminUsers.getById(adminUserId);
|
|
12
|
+
if (!row) return;
|
|
13
|
+
if (row.preferred_locale === cookieLocale) return;
|
|
14
|
+
await adminStore.adminUsers.setPreferredLocale(adminUserId, cookieLocale);
|
|
15
|
+
}
|
|
16
|
+
export { reconcileLocaleAfterSignIn };
|
|
@@ -3,10 +3,9 @@ import { getRequest, getRequestHeader } from "@tanstack/react-start/server";
|
|
|
3
3
|
import { readAccessTokenCookie, readRefreshTokenCookie, setSessionCookies } from "@byline/client/server";
|
|
4
4
|
import { getServerConfig } from "@byline/core";
|
|
5
5
|
import { passwordSignInSchema } from "@byline/core/validation";
|
|
6
|
-
import { readAdminLocaleCookie } from "../../i18n/locale-cookie.js";
|
|
7
|
-
import { bylineCore } from "../../integrations/byline-core.js";
|
|
8
6
|
import { normalizeClientIp } from "../../integrations/client-ip.js";
|
|
9
7
|
import { wasSignInBodyChecked } from "../../integrations/sign-in-body.js";
|
|
8
|
+
import { reconcileLocaleAfterSignIn } from "./reconcile-locale.js";
|
|
10
9
|
const adminSignIn = createServerFn({
|
|
11
10
|
method: 'POST'
|
|
12
11
|
}).validator((input)=>{
|
|
@@ -104,17 +103,4 @@ const adminSignIn = createServerFn({
|
|
|
104
103
|
sessionId: result.sessionId
|
|
105
104
|
};
|
|
106
105
|
});
|
|
107
|
-
async function reconcileLocaleAfterSignIn(adminUserId) {
|
|
108
|
-
const cookieLocale = readAdminLocaleCookie();
|
|
109
|
-
if (null == cookieLocale) return;
|
|
110
|
-
const core = bylineCore();
|
|
111
|
-
const locales = core.config.i18n.admin.locales;
|
|
112
|
-
if (!locales.includes(cookieLocale)) return;
|
|
113
|
-
const adminStore = core.adminStore;
|
|
114
|
-
if (null == adminStore) return;
|
|
115
|
-
const row = await adminStore.adminUsers.getById(adminUserId);
|
|
116
|
-
if (!row) return;
|
|
117
|
-
if (row.preferred_locale === cookieLocale) return;
|
|
118
|
-
await adminStore.adminUsers.setPreferredLocale(adminUserId, cookieLocale);
|
|
119
|
-
}
|
|
120
106
|
export { adminSignIn };
|
package/package.json
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "6.
|
|
6
|
+
"version": "6.8.0",
|
|
7
7
|
"engines": {
|
|
8
|
-
"node": ">=
|
|
8
|
+
"node": ">=22.22.2"
|
|
9
9
|
},
|
|
10
10
|
"description": "TanStack Start host adapter for Byline CMS — server fns, auth context, integration glue, admin shell, and route factories",
|
|
11
11
|
"keywords": [
|
|
@@ -119,15 +119,15 @@
|
|
|
119
119
|
}
|
|
120
120
|
},
|
|
121
121
|
"dependencies": {
|
|
122
|
-
"@byline/admin": "6.
|
|
123
|
-
"@byline/ai": "6.
|
|
124
|
-
"@byline/analytics": "6.
|
|
125
|
-
"@byline/analytics-agent": "6.
|
|
126
|
-
"@byline/auth": "6.
|
|
127
|
-
"@byline/client": "6.
|
|
128
|
-
"@byline/core": "6.
|
|
129
|
-
"@byline/i18n": "6.
|
|
130
|
-
"@byline/ui": "6.
|
|
122
|
+
"@byline/admin": "6.8.0",
|
|
123
|
+
"@byline/ai": "6.8.0",
|
|
124
|
+
"@byline/analytics": "6.8.0",
|
|
125
|
+
"@byline/analytics-agent": "6.8.0",
|
|
126
|
+
"@byline/auth": "6.8.0",
|
|
127
|
+
"@byline/client": "6.8.0",
|
|
128
|
+
"@byline/core": "6.8.0",
|
|
129
|
+
"@byline/i18n": "6.8.0",
|
|
130
|
+
"@byline/ui": "6.8.0",
|
|
131
131
|
"@dnd-kit/core": "^6.3.1",
|
|
132
132
|
"@dnd-kit/sortable": "^10.0.0",
|
|
133
133
|
"clsx": "^2.1.1",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
:global(.byline-role-detail-card) {
|
|
18
18
|
margin-bottom: 1.5rem;
|
|
19
19
|
border: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
|
|
20
|
-
border-radius:
|
|
20
|
+
border-radius: var(--border-radius-sm);
|
|
21
21
|
background-color: var(--canvas-25);
|
|
22
22
|
padding: 1rem;
|
|
23
23
|
}
|
|
@@ -88,7 +88,7 @@ export function DeleteRole({ role, onClose }: DeleteRoleProps) {
|
|
|
88
88
|
<div className={cx('byline-role-delete-actions', styles.actions)}>
|
|
89
89
|
<Button
|
|
90
90
|
type="button"
|
|
91
|
-
intent="
|
|
91
|
+
intent="noeffect"
|
|
92
92
|
size="sm"
|
|
93
93
|
onClick={onClose}
|
|
94
94
|
disabled={pending}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* .byline-admin-user-drawer-body — drawer content container
|
|
19
19
|
* .byline-admin-user-drawer-scroll — drawer scrollable inner viewport
|
|
20
20
|
* .byline-admin-user-modal — modal container sizing
|
|
21
|
+
* .byline-admin-user-account-link — own-account link in the Passkeys card
|
|
21
22
|
* .byline-admin-user-modal-head — modal header row
|
|
22
23
|
*/
|
|
23
24
|
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
border: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
|
|
51
52
|
background-color: var(--canvas-25);
|
|
52
53
|
padding: 1rem;
|
|
53
|
-
border-radius:
|
|
54
|
+
border-radius: var(--border-radius-sm);
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
:is([data-theme="dark"], :global(.dark)) .section,
|
|
@@ -197,3 +198,8 @@
|
|
|
197
198
|
justify-content: space-between;
|
|
198
199
|
margin-bottom: 1rem;
|
|
199
200
|
}
|
|
201
|
+
|
|
202
|
+
.accountLink,
|
|
203
|
+
:global(.byline-admin-user-account-link) {
|
|
204
|
+
text-decoration: underline;
|
|
205
|
+
}
|
|
@@ -15,6 +15,7 @@ import { useRouter } from '@tanstack/react-router'
|
|
|
15
15
|
import { UserRoles } from '@byline/admin/admin-users/components/roles'
|
|
16
16
|
import { SetPassword } from '@byline/admin/admin-users/components/set-password'
|
|
17
17
|
import { UpdateUser } from '@byline/admin/admin-users/components/update'
|
|
18
|
+
import { PasskeyManager } from '@byline/admin/auth/components/passkey-manager'
|
|
18
19
|
import { LocalDateTime } from '@byline/admin/react'
|
|
19
20
|
import { useTranslation } from '@byline/i18n/react'
|
|
20
21
|
import {
|
|
@@ -28,6 +29,8 @@ import {
|
|
|
28
29
|
} from '@byline/ui/react'
|
|
29
30
|
import cx from 'clsx'
|
|
30
31
|
|
|
32
|
+
import { getAdminRoutePath } from '../../routes/admin-path.js'
|
|
33
|
+
import { Link } from '../chrome/loose-router.js'
|
|
31
34
|
import styles from './container.module.css'
|
|
32
35
|
import { DeleteUser } from './delete.js'
|
|
33
36
|
import type { AdminRoleResponse } from '../../server-fns/admin-roles/index.js'
|
|
@@ -266,9 +269,7 @@ export function AccountContainer({ user, allRoles, initialUserRoles }: AccountCo
|
|
|
266
269
|
</p>
|
|
267
270
|
</div>
|
|
268
271
|
</ContainerSection>
|
|
269
|
-
</div>
|
|
270
272
|
|
|
271
|
-
<div className={cx('byline-admin-user-column', styles.column)}>
|
|
272
273
|
<ContainerSection
|
|
273
274
|
title={t('adminUsers.detail.sections.roles')}
|
|
274
275
|
onEdit={openDrawer('roles')}
|
|
@@ -289,7 +290,9 @@ export function AccountContainer({ user, allRoles, initialUserRoles }: AccountCo
|
|
|
289
290
|
{t('adminUsers.detail.editRolesButton')}
|
|
290
291
|
</Button>
|
|
291
292
|
</ContainerSection>
|
|
293
|
+
</div>
|
|
292
294
|
|
|
295
|
+
<div className={cx('byline-admin-user-column', styles.column)}>
|
|
293
296
|
<ContainerSection
|
|
294
297
|
title={t('adminUsers.detail.sections.password')}
|
|
295
298
|
onEdit={openDrawer('set_password')}
|
|
@@ -303,6 +306,23 @@ export function AccountContainer({ user, allRoles, initialUserRoles }: AccountCo
|
|
|
303
306
|
</Button>
|
|
304
307
|
</ContainerSection>
|
|
305
308
|
|
|
309
|
+
<PasskeyManager
|
|
310
|
+
targetUserId={currentUser.id}
|
|
311
|
+
targetIsSuperAdmin={currentUser.is_super_admin}
|
|
312
|
+
section={ContainerSection}
|
|
313
|
+
ownAccountNotice={
|
|
314
|
+
<p className={cx('byline-admin-user-line-spaced', styles.lineSpaced)}>
|
|
315
|
+
{t('adminUsers.detail.passkeys.ownAccount')}{' '}
|
|
316
|
+
<Link
|
|
317
|
+
to={getAdminRoutePath('account')}
|
|
318
|
+
className={cx('byline-admin-user-account-link', styles.accountLink)}
|
|
319
|
+
>
|
|
320
|
+
{t('adminUsers.detail.passkeys.openAccount')}
|
|
321
|
+
</Link>
|
|
322
|
+
</p>
|
|
323
|
+
}
|
|
324
|
+
/>
|
|
325
|
+
|
|
306
326
|
<ContainerSection title={t('adminUsers.detail.sections.delete')}>
|
|
307
327
|
<p className={cx('byline-admin-user-line-spaced', styles.lineSpaced)}>
|
|
308
328
|
{t('adminUsers.detail.delete.intro')}
|
|
@@ -100,7 +100,7 @@ export function DeleteUser({ user, onClose }: DeleteUserProps) {
|
|
|
100
100
|
<div className={cx('byline-admin-user-delete-actions', styles.actions)}>
|
|
101
101
|
<Button
|
|
102
102
|
type="button"
|
|
103
|
-
intent="
|
|
103
|
+
intent="noeffect"
|
|
104
104
|
size="sm"
|
|
105
105
|
onClick={onClose}
|
|
106
106
|
disabled={pending}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
import { adminSignIn } from '../server-fns/auth/index.js'
|
|
37
37
|
import { getCollectionDocumentVersion as serverGetCollectionDocumentVersion } from '../server-fns/collections/get.js'
|
|
38
38
|
import { setAdminLocaleFn } from '../server-fns/i18n/index.js'
|
|
39
|
+
import { passkeyAdminServices } from './passkey-admin-services.js'
|
|
39
40
|
import { acceptSession, coordinateAuthAction, notifySessionAction } from './session-coordination.js'
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -70,6 +71,8 @@ export const bylineAdminServices: BylineAdminServices = {
|
|
|
70
71
|
return result
|
|
71
72
|
},
|
|
72
73
|
|
|
74
|
+
passkeys: passkeyAdminServices,
|
|
75
|
+
|
|
73
76
|
// Account self-service
|
|
74
77
|
updateAccount,
|
|
75
78
|
changeAccountPassword,
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
|
|
9
|
+
import type { PasskeyAdminServices } from '@byline/admin/services'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
getPasskeyCapability,
|
|
13
|
+
passkeyEnrollment,
|
|
14
|
+
passkeyEnrollmentOptions,
|
|
15
|
+
passkeyList,
|
|
16
|
+
passkeyManagementContext,
|
|
17
|
+
passkeyPasswordProof,
|
|
18
|
+
passkeyProof,
|
|
19
|
+
passkeyProofOptions,
|
|
20
|
+
passkeyRemove,
|
|
21
|
+
passkeyRename,
|
|
22
|
+
passkeySignIn,
|
|
23
|
+
passkeySignInOptions,
|
|
24
|
+
} from '../server-fns/auth/passkeys.js'
|
|
25
|
+
import {
|
|
26
|
+
acceptSession,
|
|
27
|
+
coordinateAuthAction,
|
|
28
|
+
flagSessionChanged,
|
|
29
|
+
notifySessionAction,
|
|
30
|
+
requireUnchanged,
|
|
31
|
+
sessionChangedError,
|
|
32
|
+
sessionSnapshot,
|
|
33
|
+
} from './session-coordination.js'
|
|
34
|
+
|
|
35
|
+
export const passkeyAdminServices: PasskeyAdminServices = {
|
|
36
|
+
capability: () => getPasskeyCapability(),
|
|
37
|
+
captureLogin: (signIn = false) => {
|
|
38
|
+
const snapshot = sessionSnapshot()
|
|
39
|
+
const assertCurrent = () => {
|
|
40
|
+
if (!signIn) return requireUnchanged(snapshot)
|
|
41
|
+
// Explicit reauthentication may begin on an already flagged page, but any
|
|
42
|
+
// subsequent account change still invalidates this particular ceremony.
|
|
43
|
+
const current = sessionSnapshot()
|
|
44
|
+
if (current.epoch !== snapshot.epoch || current.sessionId !== snapshot.sessionId)
|
|
45
|
+
throw sessionChangedError()
|
|
46
|
+
}
|
|
47
|
+
assertCurrent()
|
|
48
|
+
return { expectedSessionId: snapshot.sessionId, assertCurrent }
|
|
49
|
+
},
|
|
50
|
+
managementContext: async (binding) => {
|
|
51
|
+
binding.assertCurrent()
|
|
52
|
+
if (!binding.expectedSessionId) throw sessionChangedError()
|
|
53
|
+
const actor = await passkeyManagementContext({
|
|
54
|
+
data: { expectedSessionId: binding.expectedSessionId },
|
|
55
|
+
})
|
|
56
|
+
binding.assertCurrent()
|
|
57
|
+
return actor
|
|
58
|
+
},
|
|
59
|
+
signInOptions: passkeySignInOptions,
|
|
60
|
+
signIn: (args, binding) =>
|
|
61
|
+
coordinateAuthAction(async () => {
|
|
62
|
+
binding.assertCurrent()
|
|
63
|
+
const result = await passkeySignIn(args)
|
|
64
|
+
acceptSession(result.sessionId)
|
|
65
|
+
notifySessionAction()
|
|
66
|
+
return result
|
|
67
|
+
}),
|
|
68
|
+
passwordProof: passkeyPasswordProof,
|
|
69
|
+
proofOptions: passkeyProofOptions,
|
|
70
|
+
proof: passkeyProof,
|
|
71
|
+
enrollmentOptions: passkeyEnrollmentOptions,
|
|
72
|
+
enrollment: passkeyEnrollment,
|
|
73
|
+
list: passkeyList,
|
|
74
|
+
rename: passkeyRename,
|
|
75
|
+
remove: async (args) => {
|
|
76
|
+
// The mutation middleware queues its fetch, including cookie application;
|
|
77
|
+
// it releases the queue before a pre-handler renewal can acquire it.
|
|
78
|
+
const result = await passkeyRemove(args)
|
|
79
|
+
if (result.signedOut) {
|
|
80
|
+
flagSessionChanged()
|
|
81
|
+
notifySessionAction()
|
|
82
|
+
}
|
|
83
|
+
return result
|
|
84
|
+
},
|
|
85
|
+
}
|
|
@@ -119,3 +119,76 @@ describe('coordinated business resend', () => {
|
|
|
119
119
|
expect(mocks.renew).toHaveBeenCalledOnce()
|
|
120
120
|
})
|
|
121
121
|
})
|
|
122
|
+
|
|
123
|
+
describe('cookie mutation queue', () => {
|
|
124
|
+
const mutation = () =>
|
|
125
|
+
middleware.adminSessionMutationMiddleware.options.client?.({
|
|
126
|
+
next: (options: any) =>
|
|
127
|
+
options.fetch('http://localhost/remove', { method: 'POST', body: '{}' }),
|
|
128
|
+
} as any)
|
|
129
|
+
it('releases the auth queue before renewal, then queues the single safe resend', async () => {
|
|
130
|
+
const order: string[] = []
|
|
131
|
+
vi.stubGlobal('navigator', {
|
|
132
|
+
locks: {
|
|
133
|
+
request: async (_name: string, work: () => Promise<unknown>) => {
|
|
134
|
+
order.push('lock')
|
|
135
|
+
try {
|
|
136
|
+
return await work()
|
|
137
|
+
} finally {
|
|
138
|
+
order.push('unlock')
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
const fetch = vi
|
|
144
|
+
.fn()
|
|
145
|
+
.mockImplementationOnce(async () => {
|
|
146
|
+
order.push('expired')
|
|
147
|
+
return boundary('renew')
|
|
148
|
+
})
|
|
149
|
+
.mockImplementation(async () => {
|
|
150
|
+
order.push('handled')
|
|
151
|
+
return boundary('handled')
|
|
152
|
+
})
|
|
153
|
+
vi.stubGlobal('fetch', fetch)
|
|
154
|
+
mocks.renew.mockImplementation(async () => {
|
|
155
|
+
order.push('renew')
|
|
156
|
+
return { sessionId: 'A' }
|
|
157
|
+
})
|
|
158
|
+
await mutation()
|
|
159
|
+
expect(order).toEqual([
|
|
160
|
+
'lock',
|
|
161
|
+
'expired',
|
|
162
|
+
'unlock',
|
|
163
|
+
'lock',
|
|
164
|
+
'renew',
|
|
165
|
+
'unlock',
|
|
166
|
+
'lock',
|
|
167
|
+
'handled',
|
|
168
|
+
'unlock',
|
|
169
|
+
])
|
|
170
|
+
expect(fetch).toHaveBeenCalledTimes(2)
|
|
171
|
+
})
|
|
172
|
+
it('rechecks identity after waiting for a competing auth action', async () => {
|
|
173
|
+
const fetch = vi.fn()
|
|
174
|
+
vi.stubGlobal('fetch', fetch)
|
|
175
|
+
const competing = coordination.coordinateAuthAction(async () => {
|
|
176
|
+
coordination.acceptSession('B')
|
|
177
|
+
})
|
|
178
|
+
const result = mutation()
|
|
179
|
+
await competing
|
|
180
|
+
await expect(result).rejects.toThrow('Session changed')
|
|
181
|
+
expect(fetch).not.toHaveBeenCalled()
|
|
182
|
+
})
|
|
183
|
+
it('does not replay a failed mutation after its handler started', async () => {
|
|
184
|
+
const fetch = vi
|
|
185
|
+
.fn()
|
|
186
|
+
.mockResolvedValue(
|
|
187
|
+
new Response('failed', { status: 500, headers: { 'x-byline-auth-boundary': 'handled' } })
|
|
188
|
+
)
|
|
189
|
+
vi.stubGlobal('fetch', fetch)
|
|
190
|
+
await mutation()
|
|
191
|
+
expect(fetch).toHaveBeenCalledOnce()
|
|
192
|
+
expect(mocks.renew).not.toHaveBeenCalled()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -14,7 +14,9 @@ let changed = false
|
|
|
14
14
|
let renewal: Promise<void> | undefined
|
|
15
15
|
const listeners = new Set<() => void>()
|
|
16
16
|
export const sessionChangedError = () =>
|
|
17
|
-
new Error('Session changed. Acknowledge the active account before continuing.')
|
|
17
|
+
Object.assign(new Error('Session changed. Acknowledge the active account before continuing.'), {
|
|
18
|
+
code: 'ERR_SESSION_CHANGED',
|
|
19
|
+
})
|
|
18
20
|
export function expectedSession() {
|
|
19
21
|
if (typeof window === 'undefined') return null
|
|
20
22
|
if (!expected) expected = window.sessionStorage.getItem(storageKey)
|