@cosmicdrift/kumiko-bundled-features 0.220.1 → 0.222.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/package.json +10 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +85 -11
- package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
- package/src/audit/__tests__/audit.integration.test.ts +3 -1
- package/src/audit/__tests__/constants.test.ts +13 -0
- package/src/audit/constants.ts +10 -1
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +2 -0
- package/src/audit/web/audit-log-detail-screen.tsx +35 -15
- package/src/audit/web/audit-log-screen.tsx +45 -33
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +15 -10
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/seeding.ts +3 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/auth-mfa/hmac-token-codec.ts +75 -0
- package/src/auth-mfa/mfa-challenge-token.ts +10 -65
- package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/channel-email/__tests__/email-channel.test.ts +19 -0
- package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
- package/src/channel-email/email-channel.ts +14 -7
- package/src/channel-email/pii-guard.ts +8 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/crypto-shredding/constants.ts +1 -0
- package/src/crypto-shredding/feature.ts +6 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
- package/src/crypto-shredding/index.ts +2 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +8 -3
- package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
- package/src/files-provider-s3/s3-provider.ts +45 -0
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
- package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
- package/src/files-tenant-data/changes.json +1 -0
- package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
- package/src/files-tenant-data/hooks.ts +93 -0
- package/src/files-tenant-data/index.ts +58 -0
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/db/queries/cleanup.ts +17 -0
- package/src/form-draft/handlers/cleanup.job.ts +51 -30
- package/src/form-draft/handlers/discard.write.ts +33 -15
- package/src/form-draft/handlers/get.query.ts +4 -2
- package/src/form-draft/handlers/list.query.ts +4 -1
- package/src/form-draft/lookup.ts +12 -5
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +286 -23
- package/src/jobs/constants.ts +1 -0
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/handlers/retry.write.ts +26 -6
- package/src/jobs/i18n.ts +3 -0
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/sessions/session-callbacks.ts +1 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
- package/src/tags/feature.ts +7 -4
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
- package/src/tenant/changes.json +1 -1
- package/src/tenant/constants.ts +5 -1
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/remove-member.write.ts +12 -0
- package/src/tenant/handlers/team-list.query.ts +2 -2
- package/src/tenant/handlers/update-member-roles.write.ts +11 -38
- package/src/tenant/i18n.ts +2 -2
- package/src/tenant/last-tenant-admin.ts +39 -0
- package/src/tenant/screens.ts +2 -0
- package/src/tenant/seeding.ts +3 -1
- package/src/tenant-lifecycle/feature.ts +1 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
- package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/src/tenant-lifecycle/stages.ts +5 -0
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +1 -1
- package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/changes.json +12 -5
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +18 -5
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
- package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
- package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +33 -6
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
- package/src/user-profile/web/profile-screen.tsx +1 -1
package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts
CHANGED
|
@@ -166,6 +166,16 @@ function aliceSession(): SessionUser {
|
|
|
166
166
|
return { id: aliceId, tenantId: TENANT_A_ID, roles: ["Admin"] };
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// auth-mfa:write:verify runs pre-session (access: { roles: ["all"] }) —
|
|
170
|
+
// dispatched here the same way the framework's /api/auth/mfa/verify route
|
|
171
|
+
// would, with a guest identity. The handler derives everything it needs
|
|
172
|
+
// from the challenge token, not from this actor.
|
|
173
|
+
const GUEST: SessionUser = {
|
|
174
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
175
|
+
tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
|
|
176
|
+
roles: ["all"],
|
|
177
|
+
};
|
|
178
|
+
|
|
169
179
|
async function inviteEmail(email: string, role: string): Promise<string> {
|
|
170
180
|
await stack.http.writeOk(AuthHandlers.inviteCreate, { email, role }, aliceSession());
|
|
171
181
|
const sent = emailTransport.sent.at(-1);
|
|
@@ -230,6 +240,79 @@ describe("invite-accept-with-login: MFA gate", () => {
|
|
|
230
240
|
// No session — a challenge, not a JWT.
|
|
231
241
|
expect(body.token).toBeUndefined();
|
|
232
242
|
});
|
|
243
|
+
|
|
244
|
+
// fw#2333 — completing the challenge via auth-mfa:write:verify must carry
|
|
245
|
+
// the invitee's locale into the minted session, same as a plain login.
|
|
246
|
+
async function acceptWithLoginThenVerifyMfa(opts: {
|
|
247
|
+
actorId: number;
|
|
248
|
+
email: string;
|
|
249
|
+
password: string;
|
|
250
|
+
locale?: string;
|
|
251
|
+
}): Promise<SessionUser> {
|
|
252
|
+
const mfaActor = createTestUser({ id: opts.actorId, tenantId: TENANT_A_ID, roles: ["User"] });
|
|
253
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
254
|
+
AuthMfaHandlers.enableStart,
|
|
255
|
+
{ accountLabel: opts.email },
|
|
256
|
+
mfaActor,
|
|
257
|
+
);
|
|
258
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
259
|
+
const secret = base32Decode(secretParam);
|
|
260
|
+
await stack.http.writeOk(
|
|
261
|
+
AuthMfaHandlers.enableConfirm,
|
|
262
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
263
|
+
mfaActor,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
await seedRow(stack.db, userTable, {
|
|
267
|
+
id: mfaActor.id,
|
|
268
|
+
tenantId: TENANT_A_ID,
|
|
269
|
+
email: opts.email,
|
|
270
|
+
passwordHash: await hashPassword(opts.password),
|
|
271
|
+
displayName: "MFA Invitee",
|
|
272
|
+
...(opts.locale !== undefined && { locale: opts.locale }),
|
|
273
|
+
emailVerified: true,
|
|
274
|
+
roles: "[]",
|
|
275
|
+
status: USER_STATUS.Active,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const token = await inviteEmail(opts.email, "Editor");
|
|
279
|
+
const acceptRes = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
280
|
+
token,
|
|
281
|
+
email: opts.email,
|
|
282
|
+
password: opts.password,
|
|
283
|
+
});
|
|
284
|
+
expect(acceptRes.status).toBe(200);
|
|
285
|
+
const acceptBody = (await acceptRes.json()) as { challengeToken?: string };
|
|
286
|
+
if (!acceptBody.challengeToken) {
|
|
287
|
+
throw new Error("invite-accept-with-login did not return a challenge token");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const verified = await stack.http.writeOk<{ session: SessionUser }>(
|
|
291
|
+
AuthMfaHandlers.verify,
|
|
292
|
+
{ challengeToken: acceptBody.challengeToken, code: currentTotpCode(secret) },
|
|
293
|
+
GUEST,
|
|
294
|
+
);
|
|
295
|
+
return verified.session;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
test("completing the mfa/verify challenge carries the invitee's locale into the session", async () => {
|
|
299
|
+
const session = await acceptWithLoginThenVerifyMfa({
|
|
300
|
+
actorId: 702,
|
|
301
|
+
email: "mfa-locale-invitee@example.com",
|
|
302
|
+
password: "mfa-locale-invitee-pw-1234",
|
|
303
|
+
locale: "de-DE",
|
|
304
|
+
});
|
|
305
|
+
expect(session.locale).toBe("de-DE");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("completing the mfa/verify challenge without a stored locale omits the claim", async () => {
|
|
309
|
+
const session = await acceptWithLoginThenVerifyMfa({
|
|
310
|
+
actorId: 703,
|
|
311
|
+
email: "mfa-nolocale-invitee@example.com",
|
|
312
|
+
password: "mfa-nolocale-invitee-pw-1234",
|
|
313
|
+
});
|
|
314
|
+
expect(session.locale).toBeUndefined();
|
|
315
|
+
});
|
|
233
316
|
});
|
|
234
317
|
|
|
235
318
|
describe("invite-accept-with-login: account-status gate", () => {
|
|
@@ -369,6 +369,42 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
|
|
|
369
369
|
// Membership added
|
|
370
370
|
const memberships = await selectMany(stack.db, tenantMembershipsTable, { userId: bobId });
|
|
371
371
|
expect(memberships).toHaveLength(2);
|
|
372
|
+
|
|
373
|
+
// fw#2333 — Bob has no stored locale, so the JWT must carry no locale
|
|
374
|
+
// claim at all (not null, not "").
|
|
375
|
+
const payload = await stack.jwt.verify(body.token as string);
|
|
376
|
+
expect(payload.locale).toBeUndefined();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("Bob accepts with a locale → JWT retains the locale", async () => {
|
|
380
|
+
await asRawClient(stack.db).unsafe(
|
|
381
|
+
`UPDATE "${userTable.tableName}" SET locale = $1 WHERE id = $2`,
|
|
382
|
+
["de-DE", bobId],
|
|
383
|
+
);
|
|
384
|
+
try {
|
|
385
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
386
|
+
|
|
387
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
388
|
+
token,
|
|
389
|
+
email: BOB_EMAIL,
|
|
390
|
+
password: BOB_PASSWORD,
|
|
391
|
+
});
|
|
392
|
+
expect(res.status).toBe(200);
|
|
393
|
+
const body = (await res.json()) as { token?: string };
|
|
394
|
+
expect(body.token).toBeTypeOf("string");
|
|
395
|
+
if (!body.token) throw new Error("invite acceptance did not return a token");
|
|
396
|
+
|
|
397
|
+
const payload = await stack.jwt.verify(body.token);
|
|
398
|
+
expect(payload.locale).toBe("de-DE");
|
|
399
|
+
} finally {
|
|
400
|
+
// Bob is shared across this describe block (other tests rely on his
|
|
401
|
+
// membership state persisting) — restore his locale so later tests
|
|
402
|
+
// don't silently inherit it.
|
|
403
|
+
await asRawClient(stack.db).unsafe(
|
|
404
|
+
`UPDATE "${userTable.tableName}" SET locale = NULL WHERE id = $1`,
|
|
405
|
+
[bobId],
|
|
406
|
+
);
|
|
407
|
+
}
|
|
372
408
|
});
|
|
373
409
|
|
|
374
410
|
test("Bob accepts with a timezone → JWT retains the timezone", async () => {
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
-
import
|
|
10
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
12
|
import {
|
|
12
13
|
createTestUser,
|
|
13
14
|
setupTestStack,
|
|
@@ -18,11 +19,14 @@ import {
|
|
|
18
19
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
20
|
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
20
21
|
import {
|
|
22
|
+
AuthMfaHandlers,
|
|
23
|
+
base32Decode,
|
|
21
24
|
createAuthMfaFeature,
|
|
22
25
|
mfaRequiredConfigHandle,
|
|
23
26
|
mfaStatusCheckerFromFeature,
|
|
24
27
|
} from "../../auth-mfa";
|
|
25
28
|
import { userMfaEntity } from "../../auth-mfa/schema/user-mfa";
|
|
29
|
+
import { currentTotpCode } from "../../auth-mfa/totp";
|
|
26
30
|
import { ConfigHandlers, createConfigFeature } from "../../config";
|
|
27
31
|
import { createConfigResolver } from "../../config/resolver";
|
|
28
32
|
import { configValuesTable } from "../../config/table";
|
|
@@ -42,8 +46,20 @@ let stack: TestStack;
|
|
|
42
46
|
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
43
47
|
const TENANT_ID: TenantId = testTenantId(400);
|
|
44
48
|
|
|
49
|
+
// auth-mfa:write:enable-start-preauth / enable-confirm-preauth both run
|
|
50
|
+
// pre-session (access: { roles: ["all"] }) — dispatched here the same way
|
|
51
|
+
// the framework's /api/auth/mfa/preauth-enable-start and preauth-confirm
|
|
52
|
+
// routes would, with a guest identity. Both handlers derive everything they
|
|
53
|
+
// need from the verified token, not from this actor.
|
|
54
|
+
const GUEST: SessionUser = {
|
|
55
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
56
|
+
tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
|
|
57
|
+
roles: ["all"],
|
|
58
|
+
};
|
|
59
|
+
|
|
45
60
|
beforeAll(async () => {
|
|
46
61
|
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
62
|
+
configureEntityFieldEncryption(encryption);
|
|
47
63
|
const resolver = createConfigResolver({ cipher: encryption });
|
|
48
64
|
const authMfaFeature = createAuthMfaFeature({
|
|
49
65
|
setupTokenSecret: "test-mfa-setup-token-secret-at-least-32-bytes!!",
|
|
@@ -126,4 +142,76 @@ describe("login: mfa-setup-required carries a verifiable preauthSetupToken", ()
|
|
|
126
142
|
expect(verified.payload).toEqual({ userId: created.id, tenantId: TENANT_ID });
|
|
127
143
|
}
|
|
128
144
|
});
|
|
145
|
+
|
|
146
|
+
// fw#2333 — completing the blocked login via auth-mfa:write:enable-start-
|
|
147
|
+
// preauth + enable-confirm-preauth must carry the user's stored locale
|
|
148
|
+
// into the session it mints, same as a plain login.
|
|
149
|
+
async function completeMfaSetupRequiredLogin(opts: {
|
|
150
|
+
actorId: number;
|
|
151
|
+
email: string;
|
|
152
|
+
locale?: string;
|
|
153
|
+
}): Promise<SessionUser> {
|
|
154
|
+
const password = "correct-horse-battery-2026";
|
|
155
|
+
const hash = await hashPassword(password);
|
|
156
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
157
|
+
UserHandlers.create,
|
|
158
|
+
{
|
|
159
|
+
email: opts.email,
|
|
160
|
+
passwordHash: hash,
|
|
161
|
+
displayName: "Unenrolled",
|
|
162
|
+
...(opts.locale !== undefined && { locale: opts.locale }),
|
|
163
|
+
},
|
|
164
|
+
createTestUser({ id: opts.actorId, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
|
|
165
|
+
);
|
|
166
|
+
await seedTenantMembership(stack.db, {
|
|
167
|
+
userId: created.id,
|
|
168
|
+
tenantId: TENANT_ID,
|
|
169
|
+
roles: ["User"],
|
|
170
|
+
});
|
|
171
|
+
await stack.http.writeOk(
|
|
172
|
+
ConfigHandlers.set,
|
|
173
|
+
{ key: mfaRequiredConfigHandle.name, value: "all" },
|
|
174
|
+
createTestUser({ id: opts.actorId + 1, tenantId: TENANT_ID, roles: ["Admin"] }),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
178
|
+
email: opts.email,
|
|
179
|
+
password,
|
|
180
|
+
});
|
|
181
|
+
expect(loginRes.status).toBe(200);
|
|
182
|
+
const loginBody = await loginRes.json();
|
|
183
|
+
expect(typeof loginBody.preauthSetupToken).toBe("string");
|
|
184
|
+
|
|
185
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
186
|
+
AuthMfaHandlers.enableStartPreauth,
|
|
187
|
+
{ preauthSetupToken: loginBody.preauthSetupToken, accountLabel: opts.email },
|
|
188
|
+
GUEST,
|
|
189
|
+
);
|
|
190
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
191
|
+
const secret = base32Decode(secretParam);
|
|
192
|
+
|
|
193
|
+
const confirmed = await stack.http.writeOk<{ session: SessionUser }>(
|
|
194
|
+
AuthMfaHandlers.enableConfirmPreauth,
|
|
195
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
196
|
+
GUEST,
|
|
197
|
+
);
|
|
198
|
+
return confirmed.session;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test("completing mfa-setup-required via enable-start/confirm-preauth carries the user's locale", async () => {
|
|
202
|
+
const session = await completeMfaSetupRequiredLogin({
|
|
203
|
+
actorId: 501,
|
|
204
|
+
email: "preauth-locale@example.com",
|
|
205
|
+
locale: "de-DE",
|
|
206
|
+
});
|
|
207
|
+
expect(session.locale).toBe("de-DE");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("completing mfa-setup-required via enable-start/confirm-preauth without a stored locale omits the claim", async () => {
|
|
211
|
+
const session = await completeMfaSetupRequiredLogin({
|
|
212
|
+
actorId: 503,
|
|
213
|
+
email: "preauth-nolocale@example.com",
|
|
214
|
+
});
|
|
215
|
+
expect(session.locale).toBeUndefined();
|
|
216
|
+
});
|
|
129
217
|
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
|
|
3
|
+
import { dispatchMagicLinkMail } from "../magic-link-mail";
|
|
4
|
+
|
|
5
|
+
describe("dispatchMagicLinkMail appUrl locale negotiation", () => {
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
registerMailTranslations("de", { "test.unused": "x" });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("negotiates de-AT down to de for language-in-path appUrl", async () => {
|
|
11
|
+
const seen: string[] = [];
|
|
12
|
+
await dispatchMagicLinkMail(
|
|
13
|
+
async () => {},
|
|
14
|
+
{
|
|
15
|
+
handlerName: "test",
|
|
16
|
+
notificationType: "test.mail",
|
|
17
|
+
renderContent: (args) => {
|
|
18
|
+
seen.push(args.url);
|
|
19
|
+
return {
|
|
20
|
+
subject: "s",
|
|
21
|
+
header: "h",
|
|
22
|
+
sections: [],
|
|
23
|
+
footer: "f",
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
email: "a@example.com",
|
|
29
|
+
appUrl: (locale) => `https://app.example.com/${locale}/activate`,
|
|
30
|
+
token: "tok",
|
|
31
|
+
expiresAt: "2099-01-01T00:00:00Z",
|
|
32
|
+
locale: "de-AT",
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
expect(seen[0]).toContain("https://app.example.com/de/activate");
|
|
36
|
+
expect(seen[0]).not.toContain("/de-AT/");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -173,7 +173,7 @@ describe("multi-roles: privilege-escalation blocked via field-level write-access
|
|
|
173
173
|
{
|
|
174
174
|
id: targetId,
|
|
175
175
|
version: 1,
|
|
176
|
-
changes: { roles:
|
|
176
|
+
changes: { roles: ["SystemAdmin"] },
|
|
177
177
|
},
|
|
178
178
|
tenantAdminSession,
|
|
179
179
|
);
|
|
@@ -201,7 +201,7 @@ describe("multi-roles: privilege-escalation blocked via field-level write-access
|
|
|
201
201
|
{
|
|
202
202
|
id: userId,
|
|
203
203
|
version: 1,
|
|
204
|
-
changes: { roles:
|
|
204
|
+
changes: { roles: ["SystemAdmin"] },
|
|
205
205
|
},
|
|
206
206
|
userSession,
|
|
207
207
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
4
|
+
import { LOCALE_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
4
5
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
5
6
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
7
|
import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
|
|
@@ -41,11 +42,6 @@ import { signResetToken } from "../reset-token";
|
|
|
41
42
|
// directly (no jobRunner in the test stack → inline send).
|
|
42
43
|
const emailTransport = createInMemoryTransport();
|
|
43
44
|
|
|
44
|
-
// Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand — that
|
|
45
|
-
// constant is a framework-internal implementation detail, not exported from
|
|
46
|
-
// the public /api barrel (same as TENANT_HEADER_NAME).
|
|
47
|
-
const LOCALE_HEADER_NAME = "X-Locale";
|
|
48
|
-
|
|
49
45
|
registerMailTranslations("de", localeDeBundle);
|
|
50
46
|
|
|
51
47
|
// Records the userId every time the sessions feature's auto-revoke hook
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// zusammen).
|
|
27
27
|
|
|
28
28
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
29
|
+
import { LOCALE_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
29
30
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
30
31
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
31
32
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -98,11 +99,6 @@ const tenantMembershipHookVendorFeature = defineFeature(
|
|
|
98
99
|
|
|
99
100
|
const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
|
|
100
101
|
|
|
101
|
-
// Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand — that
|
|
102
|
-
// constant is a framework-internal implementation detail, not exported from
|
|
103
|
-
// the public /api barrel (same as TENANT_HEADER_NAME).
|
|
104
|
-
const LOCALE_HEADER_NAME = "X-Locale";
|
|
105
|
-
|
|
106
102
|
registerMailTranslations("de", localeDeBundle);
|
|
107
103
|
|
|
108
104
|
// Activation mails now go through delivery (ctx.notify → channel-email). The
|
|
@@ -39,6 +39,11 @@ import {
|
|
|
39
39
|
tenantInvitationEntity,
|
|
40
40
|
tenantInvitationsTable,
|
|
41
41
|
} from "../../tenant/invitation-table";
|
|
42
|
+
// kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
|
|
43
|
+
import {
|
|
44
|
+
findForbiddenMembershipRole,
|
|
45
|
+
reservedMembershipRoleError,
|
|
46
|
+
} from "../../tenant/membership-roles";
|
|
42
47
|
// kumiko-lint-ignore cross-feature-import membership-seed-helper for a privileged cross-tenant add
|
|
43
48
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
44
49
|
// kumiko-lint-ignore cross-feature-import login-style password-check
|
|
@@ -204,6 +209,10 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
204
209
|
const dbConn = ctx.db.raw;
|
|
205
210
|
|
|
206
211
|
if (!alreadyMember) {
|
|
212
|
+
const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
|
|
213
|
+
if (forbiddenInviteRole !== undefined) {
|
|
214
|
+
return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
|
|
215
|
+
}
|
|
207
216
|
await seedTenantMembership(dbConn, {
|
|
208
217
|
userId,
|
|
209
218
|
tenantId: invitationTenantId,
|
|
@@ -31,6 +31,11 @@ import {
|
|
|
31
31
|
tenantInvitationEntity,
|
|
32
32
|
tenantInvitationsTable,
|
|
33
33
|
} from "../../tenant/invitation-table";
|
|
34
|
+
// kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
|
|
35
|
+
import {
|
|
36
|
+
findForbiddenMembershipRole,
|
|
37
|
+
reservedMembershipRoleError,
|
|
38
|
+
} from "../../tenant/membership-roles";
|
|
34
39
|
// kumiko-lint-ignore cross-feature-import direkter membership-Lookup (ungefiltert, s. alreadyMember-Kommentar)
|
|
35
40
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
36
41
|
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add (analog provisionSignupAccount)
|
|
@@ -70,6 +75,7 @@ export function createInviteAcceptHandler() {
|
|
|
70
75
|
// nutzen `roles: ["all"]` weil dort GUEST_USER mit ["all"]-role
|
|
71
76
|
// dispatched wird.
|
|
72
77
|
access: { openToAll: true },
|
|
78
|
+
// kumiko-lint-ignore complexity-budget invite branches (auth/anon/burn) stay in one handler
|
|
73
79
|
handler: async (event, ctx) => {
|
|
74
80
|
if (!ctx.redis) {
|
|
75
81
|
return writeFailure(
|
|
@@ -142,6 +148,10 @@ export function createInviteAcceptHandler() {
|
|
|
142
148
|
// ["SystemAdmin"]-Role akzeptiert; createSystemUser produziert
|
|
143
149
|
// "system"-Role die NICHT matcht. Direkt-via-Executor bypassed
|
|
144
150
|
// den Access-Check für privilegierte Cross-Tenant-Operationen.
|
|
151
|
+
const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
|
|
152
|
+
if (forbiddenInviteRole !== undefined) {
|
|
153
|
+
return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
|
|
154
|
+
}
|
|
145
155
|
await seedTenantMembership(dbConn, {
|
|
146
156
|
userId: event.user.id,
|
|
147
157
|
tenantId: invitationTenantId,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// erlaubt new-user-signup mit dem Token. Keine Enumeration durchs
|
|
18
18
|
// invite-create.
|
|
19
19
|
|
|
20
|
-
import { generateToken
|
|
20
|
+
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
21
21
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
22
22
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
23
23
|
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -41,7 +41,7 @@ import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
|
|
|
41
41
|
import type { AuthMailLocale } from "../email-templates";
|
|
42
42
|
import { renderInviteEmail } from "../email-templates";
|
|
43
43
|
import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store";
|
|
44
|
-
import { dispatchMagicLinkMail } from "../magic-link-mail";
|
|
44
|
+
import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail";
|
|
45
45
|
|
|
46
46
|
const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
|
|
47
47
|
|
|
@@ -77,6 +77,9 @@ export type InviteCreateOptions = {
|
|
|
77
77
|
// roles, and cannot raise above the inviter's max framework rank — only
|
|
78
78
|
// further restrict assignable ranked roles.
|
|
79
79
|
readonly canAssignRole?: (inviterRoles: readonly string[], targetRole: string) => boolean;
|
|
80
|
+
/** Opt-in allow-list for app-defined (unranked) roles the elevation guard
|
|
81
|
+
* would otherwise reject with unassignable_membership_role (fw#2398). */
|
|
82
|
+
readonly additionalAssignableRoles?: readonly string[];
|
|
80
83
|
};
|
|
81
84
|
|
|
82
85
|
const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
|
|
@@ -103,10 +106,15 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
103
106
|
return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
104
107
|
}
|
|
105
108
|
|
|
106
|
-
const elevationForbidden = findForbiddenRoleAssignment(
|
|
107
|
-
event.
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
const elevationForbidden = findForbiddenRoleAssignment(
|
|
110
|
+
event.user.roles,
|
|
111
|
+
[event.payload.role],
|
|
112
|
+
[],
|
|
113
|
+
);
|
|
114
|
+
if (
|
|
115
|
+
elevationForbidden !== undefined &&
|
|
116
|
+
!opts.additionalAssignableRoles?.includes(elevationForbidden)
|
|
117
|
+
) {
|
|
110
118
|
return writeFailure(unassignableMembershipRoleError(elevationForbidden));
|
|
111
119
|
}
|
|
112
120
|
|
|
@@ -168,10 +176,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
168
176
|
|
|
169
177
|
await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
|
|
170
178
|
|
|
171
|
-
|
|
172
|
-
// browser language wins when the request carries one, else the
|
|
173
|
-
// handler's static opts.locale, else ctx.locale's own default.
|
|
174
|
-
const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
|
|
179
|
+
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
175
180
|
|
|
176
181
|
await dispatchMagicLinkMail(
|
|
177
182
|
ctx.notify,
|
|
@@ -39,6 +39,11 @@ import {
|
|
|
39
39
|
tenantInvitationEntity,
|
|
40
40
|
tenantInvitationsTable,
|
|
41
41
|
} from "../../tenant/invitation-table";
|
|
42
|
+
// kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
|
|
43
|
+
import {
|
|
44
|
+
findForbiddenMembershipRole,
|
|
45
|
+
reservedMembershipRoleError,
|
|
46
|
+
} from "../../tenant/membership-roles";
|
|
42
47
|
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
|
|
43
48
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
44
49
|
// kumiko-lint-ignore cross-feature-import existence-check
|
|
@@ -143,6 +148,10 @@ export function createInviteSignupCompleteHandler() {
|
|
|
143
148
|
// Membership-Add via seed-helper (gleiches Pattern wie
|
|
144
149
|
// provisionSignupAccount — bypassed addMember access-check
|
|
145
150
|
// weil createSystemUser nicht ["SystemAdmin"] matcht).
|
|
151
|
+
const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
|
|
152
|
+
if (forbiddenInviteRole !== undefined) {
|
|
153
|
+
return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
|
|
154
|
+
}
|
|
146
155
|
await seedTenantMembership(dbConn, {
|
|
147
156
|
userId,
|
|
148
157
|
tenantId: invitationTenantId,
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// suppressing the link on the request side would be defense-in-depth, but
|
|
22
22
|
// with an enumeration risk of its own (separate concern).
|
|
23
23
|
|
|
24
|
-
import { generateToken
|
|
24
|
+
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
25
25
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
26
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
27
27
|
import { Temporal } from "temporal-polyfill";
|
|
@@ -29,7 +29,7 @@ import { z } from "zod";
|
|
|
29
29
|
import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
|
|
30
30
|
import type { AuthMailLocale } from "../email-templates";
|
|
31
31
|
import { renderActivationEmail } from "../email-templates";
|
|
32
|
-
import { dispatchMagicLinkMail } from "../magic-link-mail";
|
|
32
|
+
import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail";
|
|
33
33
|
import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
|
|
34
34
|
import {
|
|
35
35
|
invalidateExistingSignupToken,
|
|
@@ -133,15 +133,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
133
133
|
// get the same format.
|
|
134
134
|
const normalizedEmail = normalizeEmail(email);
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
// boot default, then "en" — dispatch-shared.ts), so it can't tell us
|
|
138
|
-
// whether THIS request actually carried a locale signal. Read the raw
|
|
139
|
-
// request-layer value instead: present → the browser's active
|
|
140
|
-
// language wins over opts.locale (this handler's static config);
|
|
141
|
-
// absent → opts.locale is the real, still-relevant fallback
|
|
142
|
-
// (backwards compatibility for callers that configured it and never
|
|
143
|
-
// send X-Locale), then ctx.locale's own resolved default.
|
|
144
|
-
const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
|
|
136
|
+
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
145
137
|
|
|
146
138
|
await dispatchMagicLinkMail(
|
|
147
139
|
ctx.notify,
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
// default TTL, extra skip condition) + two error codes — encoded on the
|
|
13
13
|
// spec rather than duplicated across two near-identical handler bodies.
|
|
14
14
|
|
|
15
|
-
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
16
15
|
import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
16
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
18
17
|
import type { Temporal } from "temporal-polyfill";
|
|
@@ -20,7 +19,7 @@ import { z } from "zod";
|
|
|
20
19
|
import { UserQueries } from "../../user";
|
|
21
20
|
import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
|
|
22
21
|
import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "../email-templates";
|
|
23
|
-
import { dispatchMagicLinkMail } from "../magic-link-mail";
|
|
22
|
+
import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail";
|
|
24
23
|
|
|
25
24
|
const RequestTokenSchema = z.object({
|
|
26
25
|
email: z.email(),
|
|
@@ -124,14 +123,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
124
123
|
|
|
125
124
|
const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
|
|
126
125
|
|
|
127
|
-
|
|
128
|
-
// boot default, then "en"), so it can't tell us whether THIS request
|
|
129
|
-
// actually carried a locale signal. Read the raw request-layer value
|
|
130
|
-
// instead: present → the requester's active language wins over
|
|
131
|
-
// opts.locale (this handler's static config); absent → opts.locale
|
|
132
|
-
// is the real, still-relevant fallback, then ctx.locale's own
|
|
133
|
-
// resolved default.
|
|
134
|
-
const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
|
|
126
|
+
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
135
127
|
|
|
136
128
|
await dispatchMagicLinkMail(
|
|
137
129
|
ctx.notify,
|
|
@@ -115,6 +115,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
115
115
|
"auth.signupComplete.submitting": "…",
|
|
116
116
|
"auth.signupComplete.missingToken":
|
|
117
117
|
"Activation link is missing a token. Please request a new one.",
|
|
118
|
+
"auth.signupComplete.activatedTitle": "Account activated",
|
|
118
119
|
"auth.signupComplete.activated": "Your account is active and you're signed in.",
|
|
119
120
|
"auth.signupComplete.continue": "Continue",
|
|
120
121
|
"auth.inviteAccept.title": "Accept invitation",
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// non-unsubscribable). No r.notification is declared; buildMessage falls back
|
|
7
7
|
// to the structured `data` for the renderer.
|
|
8
8
|
|
|
9
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
9
10
|
import type { NotifyFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
11
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
12
|
+
import { resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
|
|
11
13
|
import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "./email-templates";
|
|
12
14
|
|
|
13
15
|
// Per-flow constants: which notification type to dispatch and how to render the
|
|
@@ -43,6 +45,17 @@ function appendToken(appUrl: string, token: string): string {
|
|
|
43
45
|
return `${appUrl}${sep}token=${encodeURIComponent(token)}`;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// X-Locale on the request wins over the handler's static opts.locale, then
|
|
49
|
+
// ctx.locale's resolved default (always set — boot default / "en"). Reading
|
|
50
|
+
// requestContext is load-bearing: ctx.locale alone cannot tell whether THIS
|
|
51
|
+
// request carried a locale signal.
|
|
52
|
+
export function resolveHandlerMailLocale(
|
|
53
|
+
ctx: { readonly locale?: string },
|
|
54
|
+
optsLocale: string | undefined,
|
|
55
|
+
): string | undefined {
|
|
56
|
+
return requestContext.get()?.locale ?? optsLocale ?? ctx.locale;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
export async function dispatchMagicLinkMail(
|
|
47
60
|
notify: NotifyFn | undefined,
|
|
48
61
|
spec: MagicLinkMailSpec,
|
|
@@ -57,8 +70,11 @@ export async function dispatchMagicLinkMail(
|
|
|
57
70
|
});
|
|
58
71
|
}
|
|
59
72
|
const locale = params.locale ?? "en";
|
|
73
|
+
// mailT already root-falls-back; appUrl must use the same negotiated tag
|
|
74
|
+
// or language-in-path apps 404 on e.g. /de-AT/activate.
|
|
75
|
+
const urlLocale = resolveMailLocale(locale);
|
|
60
76
|
const content = spec.renderContent({
|
|
61
|
-
url: appendToken(resolveAppUrl(params.appUrl,
|
|
77
|
+
url: appendToken(resolveAppUrl(params.appUrl, urlLocale), params.token),
|
|
62
78
|
expiresAt: params.expiresAt,
|
|
63
79
|
...(params.locale !== undefined && { locale: params.locale }),
|
|
64
80
|
...(params.appName !== undefined && { appName: params.appName }),
|
|
@@ -108,7 +108,9 @@ export async function seedUserWithPassword(
|
|
|
108
108
|
* (SessionUser-Konstruktion für JWT-Mint) — sonst hätten zwei
|
|
109
109
|
* Stellen unabhängig "TenantAdmin" hardcoded und würden bei einem
|
|
110
110
|
* Refactor zu role-mismatch zwischen DB und Session leiden. */
|
|
111
|
-
|
|
111
|
+
// TenantAdmin is the canonical owner role; Admin stays for handlers that still
|
|
112
|
+
// gate on the legacy literal until those call sites move to access.admin.
|
|
113
|
+
export const INITIAL_SIGNUP_ROLES = ["TenantAdmin", "Admin"] as const;
|
|
112
114
|
|
|
113
115
|
export type ProvisionSignupAccountOptions = {
|
|
114
116
|
readonly email: string;
|
|
@@ -100,7 +100,7 @@ describe("SignupCompleteScreen", () => {
|
|
|
100
100
|
|
|
101
101
|
// Continue button targets exactly what resolveLoggedInHref computes from
|
|
102
102
|
// the response's tenantKey, via the app-supplied function-form prop.
|
|
103
|
-
const continueLink = screen.getByRole("link", { name: "Continue" })
|
|
103
|
+
const continueLink = screen.getByRole("link", { name: "Continue" });
|
|
104
104
|
expect(continueLink.getAttribute("href")).toBe("/acme/");
|
|
105
105
|
} finally {
|
|
106
106
|
window.location.assign = assignOrig;
|