@cosmicdrift/kumiko-bundled-features 0.211.0 → 0.213.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 +8 -8
- package/src/admin-shell/feature.ts +2 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
- package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
- package/src/audit/constants.ts +3 -0
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +1 -0
- package/src/audit/web/audit-log-detail-screen.tsx +28 -6
- package/src/audit/web/audit-log-screen.tsx +51 -23
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -2
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +175 -2
- package/src/auth-email-password/feature.ts +9 -4
- package/src/auth-email-password/handlers/invite-create.write.ts +14 -4
- package/src/auth-email-password/handlers/signup-request.write.ts +48 -34
- package/src/auth-email-password/handlers/token-request-handler.ts +20 -4
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/magic-link-mail.ts +11 -2
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +15 -6
- package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
- package/src/auth-email-password/web/auth-gate.tsx +1 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
- package/src/auth-email-password/web/session.tsx +1 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +29 -14
- package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
- package/src/auth-mfa/feature.ts +1 -0
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
- package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
- package/src/compliance-profiles/feature.ts +1 -5
- package/src/config/db/queries/__tests__/resolver.test.ts +64 -0
- package/src/config/db/queries/resolver.ts +5 -3
- package/src/crypto-shredding/changes.json +2 -1
- package/src/custom-fields/__tests__/feature.test.ts +42 -0
- package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
- package/src/custom-fields/constants.ts +6 -0
- package/src/custom-fields/feature.ts +56 -10
- package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
- package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
- package/src/delivery/feature.ts +1 -3
- package/src/feature-toggles/feature.ts +1 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
- package/src/folders/web/folder-manager.tsx +1 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +141 -0
- package/src/inbound-mail-foundation/watch-supervisor.ts +152 -11
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
- package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
- package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
- package/src/jobs/feature.ts +25 -1
- package/src/jobs/handlers/detail.query.ts +13 -1
- package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
- package/src/jobs/job-run-logger.ts +68 -18
- package/src/jobs/web/job-run-detail-screen.tsx +1 -0
- package/src/page-render/web.ts +1 -0
- package/src/personal-access-tokens/feature.ts +1 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
- package/src/secrets/constants.ts +12 -0
- package/src/secrets/feature.ts +35 -9
- package/src/secrets/handlers/delete.write.ts +23 -16
- package/src/secrets/handlers/list.query.ts +37 -30
- package/src/secrets/handlers/set.write.ts +43 -36
- package/src/secrets/index.ts +5 -0
- package/src/seo/feature.ts +1 -0
- package/src/tags/feature.ts +1 -0
- package/src/tags/web/tag-chip.tsx +2 -0
- package/src/tags/web/tag-manager.tsx +2 -1
- package/src/tags/web/tag-picker.tsx +1 -0
- package/src/template-resolver/web/client-plugin.tsx +2 -0
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
- package/src/user-data-rights/feature.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/src/user-profile/i18n.ts +1 -0
- package/src/user-profile/web/profile-screen.tsx +1 -1
|
@@ -29,12 +29,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
|
|
29
29
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
30
30
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
31
31
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
+
import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
|
|
32
33
|
import {
|
|
33
34
|
setupTestStack,
|
|
34
35
|
type TestStack,
|
|
35
36
|
unsafeCreateEntityTable,
|
|
36
37
|
unsafePushTables,
|
|
37
38
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
39
|
+
import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
|
|
38
40
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
39
41
|
import { createConfigFeature } from "../../config";
|
|
40
42
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -96,6 +98,13 @@ const tenantMembershipHookVendorFeature = defineFeature(
|
|
|
96
98
|
|
|
97
99
|
const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
|
|
98
100
|
|
|
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
|
+
registerMailTranslations("de", localeDeBundle);
|
|
107
|
+
|
|
99
108
|
// Activation mails now go through delivery (ctx.notify → channel-email). The
|
|
100
109
|
// in-memory transport captures what would be sent; route:{email} delivers
|
|
101
110
|
// directly (no jobRunner in the test stack → inline send).
|
|
@@ -177,8 +186,11 @@ beforeEach(async () => {
|
|
|
177
186
|
if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
|
|
178
187
|
});
|
|
179
188
|
|
|
180
|
-
async function postSignupRequest(
|
|
181
|
-
|
|
189
|
+
async function postSignupRequest(
|
|
190
|
+
email: string,
|
|
191
|
+
headers?: Record<string, string>,
|
|
192
|
+
): Promise<Response> {
|
|
193
|
+
return stack.http.raw("POST", "/api/auth/signup-request", { email }, headers);
|
|
182
194
|
}
|
|
183
195
|
|
|
184
196
|
async function postSignupConfirm(token: string, password: string): Promise<Response> {
|
|
@@ -425,3 +437,164 @@ describe("POST /api/auth/signup-confirm", () => {
|
|
|
425
437
|
expect(userRows).toHaveLength(0);
|
|
426
438
|
});
|
|
427
439
|
});
|
|
440
|
+
|
|
441
|
+
describe("POST /api/auth/signup-request — mail locale follows the active browser language", () => {
|
|
442
|
+
// The stack config sets no `signup.locale` (see beforeAll above) — this
|
|
443
|
+
// isolates ctx.locale as the only possible source, so a German subject
|
|
444
|
+
// here can only come from the X-Locale header, never from opts.locale.
|
|
445
|
+
test("X-Locale: de → the activation mail is rendered in German", async () => {
|
|
446
|
+
const res = await postSignupRequest("locale-de@example.com", { [LOCALE_HEADER_NAME]: "de" });
|
|
447
|
+
expect(res.status).toBe(200);
|
|
448
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
449
|
+
const sent = emailTransport.sent[0];
|
|
450
|
+
if (!sent) throw new Error("no mail sent");
|
|
451
|
+
expect(sent.subject).toContain("Account aktivieren");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("no X-Locale header → the activation mail falls back to English", async () => {
|
|
455
|
+
const res = await postSignupRequest("locale-default@example.com");
|
|
456
|
+
expect(res.status).toBe(200);
|
|
457
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
458
|
+
const sent = emailTransport.sent[0];
|
|
459
|
+
if (!sent) throw new Error("no mail sent");
|
|
460
|
+
expect(sent.subject).toContain("Activate your account");
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("POST /api/auth/signup-request — opts.locale stays a real fallback", () => {
|
|
465
|
+
// Regression guard: ctx.locale is always resolved (never undefined), so a
|
|
466
|
+
// naive `ctx.locale ?? opts.locale` would make opts.locale unreachable and
|
|
467
|
+
// silently drop every app's configured mail language the moment this PR
|
|
468
|
+
// ships. This stack configures signup.locale statically (no defaultLocale
|
|
469
|
+
// on the app context either) — a request with no X-Locale header must
|
|
470
|
+
// still land in the handler's configured language, not "en".
|
|
471
|
+
const optsLocaleTransport = createInMemoryTransport();
|
|
472
|
+
let optsLocaleStack: TestStack;
|
|
473
|
+
|
|
474
|
+
beforeAll(async () => {
|
|
475
|
+
optsLocaleStack = await setupTestStack({
|
|
476
|
+
features: [
|
|
477
|
+
createConfigFeature(),
|
|
478
|
+
createUserFeature(),
|
|
479
|
+
createTenantFeature(),
|
|
480
|
+
createTemplateResolverFeature(),
|
|
481
|
+
createRendererFoundationFeature(),
|
|
482
|
+
createDeliveryFeature(),
|
|
483
|
+
createRendererSimpleFeature(),
|
|
484
|
+
createChannelEmailFeature({
|
|
485
|
+
transport: optsLocaleTransport,
|
|
486
|
+
renderer: simpleRenderer,
|
|
487
|
+
resolveEmail: async () => "unused@test.local",
|
|
488
|
+
}),
|
|
489
|
+
createAuthEmailPasswordFeature({
|
|
490
|
+
signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL, locale: "de" },
|
|
491
|
+
}),
|
|
492
|
+
],
|
|
493
|
+
extraContext: (deps) => ({
|
|
494
|
+
...createDeliveryTestContext(deps),
|
|
495
|
+
configResolver: createConfigResolver(),
|
|
496
|
+
}),
|
|
497
|
+
authConfig: {
|
|
498
|
+
membershipQuery: "tenant:query:memberships",
|
|
499
|
+
loginHandler: AuthHandlers.login,
|
|
500
|
+
signup: {
|
|
501
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
502
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
await unsafeCreateEntityTable(optsLocaleStack.db, userEntity);
|
|
507
|
+
await unsafeCreateEntityTable(optsLocaleStack.db, tenantEntity);
|
|
508
|
+
await unsafePushTables(optsLocaleStack.db, {
|
|
509
|
+
configValuesTable,
|
|
510
|
+
tenantMembershipsTable,
|
|
511
|
+
notificationPreferencesTable,
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
afterAll(async () => {
|
|
516
|
+
await optsLocaleStack.cleanup();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("no X-Locale header → falls back to the handler's configured opts.locale, not English", async () => {
|
|
520
|
+
const res = await optsLocaleStack.http.raw("POST", "/api/auth/signup-request", {
|
|
521
|
+
email: "opts-locale@example.com",
|
|
522
|
+
});
|
|
523
|
+
expect(res.status).toBe(200);
|
|
524
|
+
expect(optsLocaleTransport.sent).toHaveLength(1);
|
|
525
|
+
const sent = optsLocaleTransport.sent[0];
|
|
526
|
+
if (!sent) throw new Error("no mail sent");
|
|
527
|
+
expect(sent.subject).toContain("Account aktivieren");
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("POST /api/auth/signup-request — appUrl as a function picks the activation link's locale", () => {
|
|
532
|
+
// This is the actual mechanism the whole PR exists for: an app with
|
|
533
|
+
// language-in-path routing (/de/activate, /en/activate, ...) passes a
|
|
534
|
+
// function instead of a plain string, and the resolved locale must land
|
|
535
|
+
// in the link the user actually clicks — not just the mail's language.
|
|
536
|
+
const appUrlTransport = createInMemoryTransport();
|
|
537
|
+
let appUrlStack: TestStack;
|
|
538
|
+
|
|
539
|
+
beforeAll(async () => {
|
|
540
|
+
appUrlStack = await setupTestStack({
|
|
541
|
+
features: [
|
|
542
|
+
createConfigFeature(),
|
|
543
|
+
createUserFeature(),
|
|
544
|
+
createTenantFeature(),
|
|
545
|
+
createTemplateResolverFeature(),
|
|
546
|
+
createRendererFoundationFeature(),
|
|
547
|
+
createDeliveryFeature(),
|
|
548
|
+
createRendererSimpleFeature(),
|
|
549
|
+
createChannelEmailFeature({
|
|
550
|
+
transport: appUrlTransport,
|
|
551
|
+
renderer: simpleRenderer,
|
|
552
|
+
resolveEmail: async () => "unused@test.local",
|
|
553
|
+
}),
|
|
554
|
+
createAuthEmailPasswordFeature({
|
|
555
|
+
signup: {
|
|
556
|
+
tokenTtlMinutes: 60,
|
|
557
|
+
appUrl: (locale) => `https://app.example.com/${locale}/activate`,
|
|
558
|
+
},
|
|
559
|
+
}),
|
|
560
|
+
],
|
|
561
|
+
extraContext: (deps) => ({
|
|
562
|
+
...createDeliveryTestContext(deps),
|
|
563
|
+
configResolver: createConfigResolver(),
|
|
564
|
+
}),
|
|
565
|
+
authConfig: {
|
|
566
|
+
membershipQuery: "tenant:query:memberships",
|
|
567
|
+
loginHandler: AuthHandlers.login,
|
|
568
|
+
signup: {
|
|
569
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
570
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
await unsafeCreateEntityTable(appUrlStack.db, userEntity);
|
|
575
|
+
await unsafeCreateEntityTable(appUrlStack.db, tenantEntity);
|
|
576
|
+
await unsafePushTables(appUrlStack.db, {
|
|
577
|
+
configValuesTable,
|
|
578
|
+
tenantMembershipsTable,
|
|
579
|
+
notificationPreferencesTable,
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
afterAll(async () => {
|
|
584
|
+
await appUrlStack.cleanup();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("X-Locale: de → the activation link in the sent mail points at /de/activate", async () => {
|
|
588
|
+
const res = await appUrlStack.http.raw(
|
|
589
|
+
"POST",
|
|
590
|
+
"/api/auth/signup-request",
|
|
591
|
+
{ email: "appurl-de@example.com" },
|
|
592
|
+
{ [LOCALE_HEADER_NAME]: "de" },
|
|
593
|
+
);
|
|
594
|
+
expect(res.status).toBe(200);
|
|
595
|
+
expect(appUrlTransport.sent).toHaveLength(1);
|
|
596
|
+
const sent = appUrlTransport.sent[0];
|
|
597
|
+
if (!sent) throw new Error("no mail sent");
|
|
598
|
+
expect(sent.html).toContain("https://app.example.com/de/activate?token=");
|
|
599
|
+
});
|
|
600
|
+
});
|
|
@@ -64,8 +64,10 @@ export type PasswordResetOptions = {
|
|
|
64
64
|
readonly tokenTtlMinutes?: number;
|
|
65
65
|
// App page that receives the magic-link; the handler appends `?token=…` and
|
|
66
66
|
// sends the mail via delivery (ctx.notify). No sendResetEmail callback — the
|
|
67
|
-
// app mounts `delivery` + a mail channel instead.
|
|
68
|
-
|
|
67
|
+
// app mounts `delivery` + a mail channel instead. Apps with language-in-path
|
|
68
|
+
// routing may pass a function to pick the right page for the resolved
|
|
69
|
+
// locale instead of a plain string.
|
|
70
|
+
readonly appUrl: string | ((locale: string) => string);
|
|
69
71
|
readonly appName?: string;
|
|
70
72
|
readonly locale?: AuthMailLocale;
|
|
71
73
|
};
|
|
@@ -80,8 +82,10 @@ export type EmailVerificationOptions = {
|
|
|
80
82
|
readonly tokenTtlMinutes?: number;
|
|
81
83
|
readonly mode?: "strict" | "off";
|
|
82
84
|
// App page that receives the magic-link; the handler appends `?token=…` and
|
|
83
|
-
// sends via delivery (ctx.notify). No sendVerificationEmail callback.
|
|
84
|
-
|
|
85
|
+
// sends via delivery (ctx.notify). No sendVerificationEmail callback. Apps
|
|
86
|
+
// with language-in-path routing may pass a function to pick the right page
|
|
87
|
+
// for the resolved locale instead of a plain string.
|
|
88
|
+
readonly appUrl: string | ((locale: string) => string);
|
|
85
89
|
readonly appName?: string;
|
|
86
90
|
readonly locale?: AuthMailLocale;
|
|
87
91
|
};
|
|
@@ -169,6 +173,7 @@ export function createAuthEmailPasswordFeature(
|
|
|
169
173
|
r.describe(
|
|
170
174
|
"Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows — password reset, email verification, magic-link self-signup, tenant invite, and account-unlock — are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`, `accountUnlock`) to `createAuthEmailPasswordFeature(opts)`. All five magic-link flows dispatch their mail through the `delivery` feature via `ctx.notify`, so mounting any of them additionally requires `delivery`. Tokens are HMAC-signed (reset/verify/unlock) or opaque-random in Redis (signup/invite). `accountUnlock` is the self-service escape hatch for `accountLockout`'s monotonic failure-counter (#1266): confirming the mailed token clears the Redis lockout state without touching the user entity. Requires the `user` and `tenant` features, and declares `JWT_SECRET` (≥ 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
|
|
171
175
|
);
|
|
176
|
+
// kumiko-lint-ignore i18n-ui-strings uiHints labels are feature-registry metadata for the admin feature catalog, not yet wired to the i18n system, see #2312
|
|
172
177
|
r.uiHints({
|
|
173
178
|
displayLabel: "Auth \u00b7 Email + Password",
|
|
174
179
|
category: "identity",
|
|
@@ -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 } from "@cosmicdrift/kumiko-framework/api";
|
|
20
|
+
import { generateToken, requestContext } 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";
|
|
@@ -62,9 +62,14 @@ export type InviteCreateOptions = {
|
|
|
62
62
|
/** TTL für den Activation-Token. Default 7 Tage. */
|
|
63
63
|
readonly tokenTtlMinutes?: number;
|
|
64
64
|
/** App page that receives the magic-link; the handler appends `?token=…`
|
|
65
|
-
* and dispatches the invite mail via delivery (ctx.notify).
|
|
66
|
-
|
|
65
|
+
* and dispatches the invite mail via delivery (ctx.notify). Apps with
|
|
66
|
+
* language-in-path routing pass a function to pick the right page for
|
|
67
|
+
* the resolved locale; everyone else keeps a plain string. */
|
|
68
|
+
readonly appUrl: string | ((locale: string) => string);
|
|
67
69
|
readonly appName?: string;
|
|
70
|
+
/** Static fallback mail locale, used only when the request itself
|
|
71
|
+
* carries no locale signal (no X-Locale header, no usable
|
|
72
|
+
* Accept-Language) — see the locale resolution in the handler below. */
|
|
68
73
|
readonly locale?: AuthMailLocale;
|
|
69
74
|
// Opt-in role-hierarchy gate. Roles are app-defined strings, not a framework
|
|
70
75
|
// concept, so the hierarchy itself must live in the app — this hook lets it
|
|
@@ -154,6 +159,11 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
154
159
|
|
|
155
160
|
await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
|
|
156
161
|
|
|
162
|
+
// Same precedence as signup/reset/verify: the inviting admin's active
|
|
163
|
+
// browser language wins when the request carries one, else the
|
|
164
|
+
// handler's static opts.locale, else ctx.locale's own default.
|
|
165
|
+
const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
|
|
166
|
+
|
|
157
167
|
await dispatchMagicLinkMail(
|
|
158
168
|
ctx.notify,
|
|
159
169
|
{
|
|
@@ -168,7 +178,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
168
178
|
token,
|
|
169
179
|
expiresAt: expiresAt.toString(),
|
|
170
180
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
171
|
-
|
|
181
|
+
locale,
|
|
172
182
|
},
|
|
173
183
|
);
|
|
174
184
|
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
// Magic-
|
|
1
|
+
// Magic-link signup, step 1 (request).
|
|
2
2
|
//
|
|
3
|
-
// User
|
|
4
|
-
//
|
|
5
|
-
// via delivery (ctx.notify) —
|
|
6
|
-
// userId
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// User enters an email → we mint an opaque random token, store it
|
|
4
|
+
// bidirectionally in Redis (token<->email), and send the activation mail
|
|
5
|
+
// via delivery (ctx.notify) — same as reset/verify. Unlike those: NO
|
|
6
|
+
// userId lookup and NO HMAC signing here (there'd be no subject — normally
|
|
7
|
+
// the user doesn't exist yet). Whether the email already has an account is
|
|
8
|
+
// deliberately decided by the confirm step, not this one.
|
|
9
9
|
//
|
|
10
10
|
// Resend: if a token is still live for this email, we invalidate it and
|
|
11
11
|
// mint a fresh one — the user gets a second mail with a NEW activation
|
|
12
12
|
// link, and the first link stops working. Deliberate: at most one live
|
|
13
13
|
// signup token per email at any time (see signup-token-store.ts).
|
|
14
14
|
//
|
|
15
|
-
// Always-200 (enumeration-safe):
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
15
|
+
// Always-200 (enumeration-safe): the response looks the same for every
|
|
16
|
+
// email, whether it's already registered or not. An email CAN already have
|
|
17
|
+
// an account (seeding or an earlier signup) — the actual gate sits
|
|
18
|
+
// deliberately in the confirm step (#365): signup-confirm rejects an
|
|
19
|
+
// already-registered email instead of reusing the existing user. Here it
|
|
20
|
+
// stays always-200 + resend-idempotent so the request path leaks nothing;
|
|
21
|
+
// suppressing the link on the request side would be defense-in-depth, but
|
|
22
|
+
// with an enumeration risk of its own (separate concern).
|
|
23
23
|
|
|
24
|
-
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
24
|
+
import { generateToken, requestContext } 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";
|
|
@@ -53,13 +53,18 @@ export type SignupRequestData =
|
|
|
53
53
|
| { readonly kind: "no-op" };
|
|
54
54
|
|
|
55
55
|
export type SignupRequestOptions = {
|
|
56
|
-
/** TTL
|
|
57
|
-
* "
|
|
56
|
+
/** TTL for the activation token. Default 24h — long enough that users
|
|
57
|
+
* can "activate tomorrow" without resend spam. */
|
|
58
58
|
readonly tokenTtlMinutes?: number;
|
|
59
59
|
/** App page that receives the magic-link; the handler appends `?token=…`
|
|
60
|
-
* and dispatches the activation mail via delivery (ctx.notify).
|
|
61
|
-
|
|
60
|
+
* and dispatches the activation mail via delivery (ctx.notify). Apps
|
|
61
|
+
* with language-in-path routing pass a function to pick the right page
|
|
62
|
+
* for the resolved locale; everyone else keeps a plain string. */
|
|
63
|
+
readonly appUrl: string | ((locale: string) => string);
|
|
62
64
|
readonly appName?: string;
|
|
65
|
+
/** Static fallback mail locale for this handler instance, used only when
|
|
66
|
+
* the request itself carries no locale signal (no X-Locale header, no
|
|
67
|
+
* usable Accept-Language) — see the locale resolution below. */
|
|
63
68
|
readonly locale?: AuthMailLocale;
|
|
64
69
|
};
|
|
65
70
|
|
|
@@ -101,22 +106,21 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
101
106
|
);
|
|
102
107
|
}
|
|
103
108
|
|
|
104
|
-
// Email
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// lowercased haben.
|
|
109
|
+
// Email normalization lives in the store (signup-token-store). The
|
|
110
|
+
// handler passes the raw email through — one source, no drift
|
|
111
|
+
// between lookup paths that lowercase differently (or not at all).
|
|
108
112
|
const email = event.payload.email;
|
|
109
113
|
|
|
110
114
|
// At most one live signup token per email: invalidate whatever's
|
|
111
115
|
// there before minting the new one (see signup-token-store.ts).
|
|
112
116
|
await invalidateExistingSignupToken(ctx.redis, email);
|
|
113
117
|
// 32 random bytes = 256 bits unguessable randomness, base64url
|
|
114
|
-
// encoded
|
|
115
|
-
// xorshift128+
|
|
116
|
-
//
|
|
117
|
-
// signup-requests
|
|
118
|
-
//
|
|
119
|
-
//
|
|
118
|
+
// encoded to 43 chars. Math.random used to be a bug here:
|
|
119
|
+
// xorshift128+ has ~128 bits of state that's reconstructible after
|
|
120
|
+
// ~5 observed outputs — an attacker could trigger their own
|
|
121
|
+
// signup-requests and predict other users' tokens. generateToken
|
|
122
|
+
// uses randomBytes from node:crypto, the same source as CSRF/
|
|
123
|
+
// session tokens.
|
|
120
124
|
const token = generateToken();
|
|
121
125
|
|
|
122
126
|
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
@@ -124,11 +128,21 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
124
128
|
|
|
125
129
|
await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
|
|
126
130
|
|
|
127
|
-
// normalizeEmail
|
|
128
|
-
//
|
|
129
|
-
//
|
|
131
|
+
// normalizeEmail from the store — one source of truth for
|
|
132
|
+
// normalization; the delivery recipient + lookup path consistently
|
|
133
|
+
// get the same format.
|
|
130
134
|
const normalizedEmail = normalizeEmail(email);
|
|
131
135
|
|
|
136
|
+
// ctx.locale always resolves to something (falls back to the app's
|
|
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;
|
|
145
|
+
|
|
132
146
|
await dispatchMagicLinkMail(
|
|
133
147
|
ctx.notify,
|
|
134
148
|
{
|
|
@@ -142,7 +156,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
142
156
|
token,
|
|
143
157
|
expiresAt: expiresAtIso,
|
|
144
158
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
145
|
-
|
|
159
|
+
locale,
|
|
146
160
|
},
|
|
147
161
|
);
|
|
148
162
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Shared factory for the request-side of out-of-band token flows
|
|
2
|
-
// (password-reset, email-verification).
|
|
2
|
+
// (password-reset, email-verification, account-unlock). All follow the
|
|
3
|
+
// same shape:
|
|
3
4
|
//
|
|
4
5
|
// POST email
|
|
5
6
|
// → resolve user (system-scoped query)
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
// default TTL, extra skip condition) + two error codes — encoded on the
|
|
12
13
|
// spec rather than duplicated across two near-identical handler bodies.
|
|
13
14
|
|
|
15
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
14
16
|
import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
17
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
16
18
|
import type { Temporal } from "temporal-polyfill";
|
|
@@ -70,9 +72,14 @@ export type TokenRequestSpec<TName extends string, TSuccessKind extends string>
|
|
|
70
72
|
export type TokenRequestOptions = {
|
|
71
73
|
readonly hmacSecret: string;
|
|
72
74
|
readonly tokenTtlMinutes?: number;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
/** App page that receives the magic-link; the handler appends `?token=…`.
|
|
76
|
+
* Apps with language-in-path routing pass a function to pick the right
|
|
77
|
+
* page for the resolved locale; everyone else keeps a plain string. */
|
|
78
|
+
readonly appUrl: string | ((locale: string) => string);
|
|
75
79
|
readonly appName?: string;
|
|
80
|
+
/** Static fallback mail locale, used only when the request itself
|
|
81
|
+
* carries no locale signal (no X-Locale header, no usable
|
|
82
|
+
* Accept-Language) — see the locale resolution in the handler below. */
|
|
76
83
|
readonly locale?: AuthMailLocale;
|
|
77
84
|
};
|
|
78
85
|
|
|
@@ -117,6 +124,15 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
117
124
|
|
|
118
125
|
const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
|
|
119
126
|
|
|
127
|
+
// ctx.locale always resolves to something (falls back to the app's
|
|
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;
|
|
135
|
+
|
|
120
136
|
await dispatchMagicLinkMail(
|
|
121
137
|
ctx.notify,
|
|
122
138
|
{
|
|
@@ -132,7 +148,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
132
148
|
token,
|
|
133
149
|
expiresAt: expiresAt.toString(),
|
|
134
150
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
135
|
-
|
|
151
|
+
locale,
|
|
136
152
|
},
|
|
137
153
|
);
|
|
138
154
|
|
|
@@ -115,6 +115,8 @@ 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.activated": "Your account is active and you're signed in.",
|
|
119
|
+
"auth.signupComplete.continue": "Continue",
|
|
118
120
|
"auth.inviteAccept.title": "Accept invitation",
|
|
119
121
|
"auth.inviteAccept.intro": "You've been invited to a workspace. Click 'Accept' to join.",
|
|
120
122
|
"auth.inviteAccept.loggedInAs": "Signed in as {email}",
|
|
@@ -21,15 +21,23 @@ export type MagicLinkMailSpec = {
|
|
|
21
21
|
|
|
22
22
|
// Per-request values: the recipient + the app page that receives the token, plus
|
|
23
23
|
// optional presentation. appUrl is the bare page URL; the token is appended here.
|
|
24
|
+
// appUrl as a function lets apps with language-in-path routing (/de/activate,
|
|
25
|
+
// /en/activate) pick the right page for `locale` — the mail is the only
|
|
26
|
+
// channel that survives a device switch (signup on desktop, open on mobile),
|
|
27
|
+
// so this is the last point where the language can still reach the link.
|
|
24
28
|
export type MagicLinkMailParams = {
|
|
25
29
|
readonly email: string;
|
|
26
|
-
readonly appUrl: string;
|
|
30
|
+
readonly appUrl: string | ((locale: string) => string);
|
|
27
31
|
readonly token: string;
|
|
28
32
|
readonly expiresAt: string;
|
|
29
33
|
readonly appName?: string;
|
|
30
34
|
readonly locale?: AuthMailLocale;
|
|
31
35
|
};
|
|
32
36
|
|
|
37
|
+
function resolveAppUrl(appUrl: string | ((locale: string) => string), locale: string): string {
|
|
38
|
+
return typeof appUrl === "function" ? appUrl(locale) : appUrl;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
function appendToken(appUrl: string, token: string): string {
|
|
34
42
|
const sep = appUrl.includes("?") ? "&" : "?";
|
|
35
43
|
return `${appUrl}${sep}token=${encodeURIComponent(token)}`;
|
|
@@ -48,8 +56,9 @@ export async function dispatchMagicLinkMail(
|
|
|
48
56
|
message: `${spec.handlerName}: ctx.notify unavailable — the delivery feature must be mounted`,
|
|
49
57
|
});
|
|
50
58
|
}
|
|
59
|
+
const locale = params.locale ?? "en";
|
|
51
60
|
const content = spec.renderContent({
|
|
52
|
-
url: appendToken(params.appUrl, params.token),
|
|
61
|
+
url: appendToken(resolveAppUrl(params.appUrl, locale), params.token),
|
|
53
62
|
expiresAt: params.expiresAt,
|
|
54
63
|
...(params.locale !== undefined && { locale: params.locale }),
|
|
55
64
|
...(params.appName !== undefined && { appName: params.appName }),
|
|
@@ -57,12 +57,10 @@ describe("SignupCompleteScreen", () => {
|
|
|
57
57
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
test("happy path: gültiges Passwort →
|
|
61
|
-
const
|
|
60
|
+
test("happy path: gültiges Passwort → Bestätigung statt sofortigem Redirect, Weiter-Button trifft resolveLoggedInHref-Ziel", async () => {
|
|
61
|
+
const assignMock = mock((_url: string | URL): void => {});
|
|
62
62
|
const assignOrig = window.location.assign.bind(window.location);
|
|
63
|
-
window.location.assign =
|
|
64
|
-
assigned.push(String(url));
|
|
65
|
-
}) as typeof window.location.assign;
|
|
63
|
+
window.location.assign = assignMock as typeof window.location.assign;
|
|
66
64
|
|
|
67
65
|
const fetchMock = mock(
|
|
68
66
|
async () =>
|
|
@@ -94,8 +92,16 @@ describe("SignupCompleteScreen", () => {
|
|
|
94
92
|
body: JSON.stringify({ token: "abc-token", password: "validpass1" }),
|
|
95
93
|
}),
|
|
96
94
|
);
|
|
97
|
-
expect(
|
|
95
|
+
expect(screen.getByText("Your account is active and you're signed in.")).toBeTruthy();
|
|
98
96
|
});
|
|
97
|
+
|
|
98
|
+
// No automatic redirect: the form is gone, but nothing navigated us away.
|
|
99
|
+
expect(assignMock).not.toHaveBeenCalled();
|
|
100
|
+
|
|
101
|
+
// Continue button targets exactly what resolveLoggedInHref computes from
|
|
102
|
+
// the response's tenantKey, via the app-supplied function-form prop.
|
|
103
|
+
const continueLink = screen.getByRole("link", { name: "Continue" }) as HTMLAnchorElement;
|
|
104
|
+
expect(continueLink.getAttribute("href")).toBe("/acme/");
|
|
99
105
|
} finally {
|
|
100
106
|
window.location.assign = assignOrig;
|
|
101
107
|
}
|
|
@@ -130,5 +136,8 @@ describe("SignupCompleteScreen", () => {
|
|
|
130
136
|
await waitFor(() => {
|
|
131
137
|
expect(screen.getByRole("alert").textContent).toMatch(/invalid|expired/i);
|
|
132
138
|
});
|
|
139
|
+
// Error path stays on the form — no confirmation, no continue link.
|
|
140
|
+
expect(screen.queryByRole("link", { name: "Continue" })).toBeNull();
|
|
141
|
+
expect(document.getElementById("signup-password")).toBeTruthy();
|
|
133
142
|
});
|
|
134
143
|
});
|
|
@@ -50,6 +50,7 @@ export type AuthCardProps = {
|
|
|
50
50
|
readonly children: ReactNode;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
// kumiko-lint-ignore no-custom-primitives Wraps usePrimitives().Card, pure domain name for the auth-screen chrome, not a primitive reimplementation
|
|
53
54
|
export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNode {
|
|
54
55
|
const { Card } = usePrimitives();
|
|
55
56
|
const shell = useAuthShell() ?? defaultAuthShell;
|
|
@@ -98,6 +99,7 @@ export function parseUrlToken(paramName = "token"): string {
|
|
|
98
99
|
// (server-injected token) short-circuits both the URL read and the scrub.
|
|
99
100
|
export function useUrlToken(override?: string, paramName = "token"): string {
|
|
100
101
|
const [token] = useState(() => override ?? parseUrlToken(paramName));
|
|
102
|
+
// kumiko-lint-ignore no-raw-hooks DOM integration: history.replaceState scrubs the token from browser history once, no framework query/mutation equivalent
|
|
101
103
|
useEffect(() => {
|
|
102
104
|
if (override !== undefined) return;
|
|
103
105
|
if (typeof window === "undefined") return;
|
|
@@ -33,6 +33,7 @@ export function ConfirmAccountUnlockScreen({
|
|
|
33
33
|
const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
|
|
34
34
|
const startedRef = useRef(false);
|
|
35
35
|
|
|
36
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
36
37
|
useEffect(() => {
|
|
37
38
|
if (status !== "verifying") return;
|
|
38
39
|
if (startedRef.current) return;
|
|
@@ -61,6 +61,7 @@ export function InviteAcceptScreen({
|
|
|
61
61
|
const acceptLoggedIn = async (): Promise<void> => {
|
|
62
62
|
setSubmitting(true);
|
|
63
63
|
setError(null);
|
|
64
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
64
65
|
const res = await fetch("/api/auth/invite-accept", {
|
|
65
66
|
method: "POST",
|
|
66
67
|
credentials: "same-origin",
|
|
@@ -83,6 +84,7 @@ export function InviteAcceptScreen({
|
|
|
83
84
|
const acceptAnonExisting = async (): Promise<void> => {
|
|
84
85
|
setSubmitting(true);
|
|
85
86
|
setError(null);
|
|
87
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
86
88
|
const res = await fetch("/api/auth/invite-accept-with-login", {
|
|
87
89
|
method: "POST",
|
|
88
90
|
credentials: "same-origin",
|
|
@@ -105,6 +107,7 @@ export function InviteAcceptScreen({
|
|
|
105
107
|
const acceptAnonNew = async (): Promise<void> => {
|
|
106
108
|
setSubmitting(true);
|
|
107
109
|
setError(null);
|
|
110
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
108
111
|
const res = await fetch("/api/auth/invite-signup-complete", {
|
|
109
112
|
method: "POST",
|
|
110
113
|
credentials: "same-origin",
|
|
@@ -128,6 +128,7 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
|
|
|
128
128
|
setState(next);
|
|
129
129
|
}, []);
|
|
130
130
|
|
|
131
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
131
132
|
useEffect(() => {
|
|
132
133
|
void doRefresh();
|
|
133
134
|
}, [doRefresh]);
|