@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.
Files changed (75) hide show
  1. package/package.json +8 -8
  2. package/src/admin-shell/feature.ts +2 -0
  3. package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
  4. package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
  5. package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
  6. package/src/audit/constants.ts +3 -0
  7. package/src/audit/feature.ts +2 -0
  8. package/src/audit/i18n.ts +1 -0
  9. package/src/audit/web/audit-log-detail-screen.tsx +28 -6
  10. package/src/audit/web/audit-log-screen.tsx +51 -23
  11. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -2
  12. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +175 -2
  13. package/src/auth-email-password/feature.ts +9 -4
  14. package/src/auth-email-password/handlers/invite-create.write.ts +14 -4
  15. package/src/auth-email-password/handlers/signup-request.write.ts +48 -34
  16. package/src/auth-email-password/handlers/token-request-handler.ts +20 -4
  17. package/src/auth-email-password/i18n.ts +2 -0
  18. package/src/auth-email-password/magic-link-mail.ts +11 -2
  19. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +15 -6
  20. package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
  21. package/src/auth-email-password/web/auth-gate.tsx +1 -0
  22. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
  23. package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
  24. package/src/auth-email-password/web/session.tsx +1 -0
  25. package/src/auth-email-password/web/signup-complete-screen.tsx +29 -14
  26. package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
  27. package/src/auth-mfa/feature.ts +1 -0
  28. package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
  29. package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
  30. package/src/compliance-profiles/feature.ts +1 -5
  31. package/src/config/db/queries/__tests__/resolver.test.ts +64 -0
  32. package/src/config/db/queries/resolver.ts +5 -3
  33. package/src/crypto-shredding/changes.json +2 -1
  34. package/src/custom-fields/__tests__/feature.test.ts +42 -0
  35. package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
  36. package/src/custom-fields/constants.ts +6 -0
  37. package/src/custom-fields/feature.ts +56 -10
  38. package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
  39. package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
  40. package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
  41. package/src/delivery/feature.ts +1 -3
  42. package/src/feature-toggles/feature.ts +1 -0
  43. package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
  44. package/src/folders/web/folder-manager.tsx +1 -0
  45. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +141 -0
  46. package/src/inbound-mail-foundation/watch-supervisor.ts +152 -11
  47. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
  48. package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
  49. package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
  50. package/src/jobs/feature.ts +25 -1
  51. package/src/jobs/handlers/detail.query.ts +13 -1
  52. package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
  53. package/src/jobs/job-run-logger.ts +68 -18
  54. package/src/jobs/web/job-run-detail-screen.tsx +1 -0
  55. package/src/page-render/web.ts +1 -0
  56. package/src/personal-access-tokens/feature.ts +1 -0
  57. package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
  58. package/src/secrets/constants.ts +12 -0
  59. package/src/secrets/feature.ts +35 -9
  60. package/src/secrets/handlers/delete.write.ts +23 -16
  61. package/src/secrets/handlers/list.query.ts +37 -30
  62. package/src/secrets/handlers/set.write.ts +43 -36
  63. package/src/secrets/index.ts +5 -0
  64. package/src/seo/feature.ts +1 -0
  65. package/src/tags/feature.ts +1 -0
  66. package/src/tags/web/tag-chip.tsx +2 -0
  67. package/src/tags/web/tag-manager.tsx +2 -1
  68. package/src/tags/web/tag-picker.tsx +1 -0
  69. package/src/template-resolver/web/client-plugin.tsx +2 -0
  70. package/src/tier-engine/feature.ts +2 -0
  71. package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
  72. package/src/user-data-rights/feature.ts +2 -0
  73. package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  74. package/src/user-profile/i18n.ts +1 -0
  75. 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(email: string): Promise<Response> {
181
- return stack.http.raw("POST", "/api/auth/signup-request", { email });
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
- readonly appUrl: string;
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
- readonly appUrl: string;
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
- readonly appUrl: string;
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
- ...(opts.locale !== undefined && { locale: opts.locale }),
181
+ locale,
172
182
  },
173
183
  );
174
184
 
@@ -1,27 +1,27 @@
1
- // Magic-Link-Signup, Step 1 (request).
1
+ // Magic-link signup, step 1 (request).
2
2
  //
3
- // User gibt Email ein → wir minten einen opaken Random-Token, speichern
4
- // ihn bidirektional in Redis (token↔email), und schicken die Activation-Mail
5
- // via delivery (ctx.notify) — wie reset/verify. Anders als die: HIER kein
6
- // userId-Lookup und kein HMAC-signing (es gäbe kein Subject — im Normalfall
7
- // existiert der User noch nicht). Ob die Email bereits ein Konto hat,
8
- // entscheidet bewusst der Confirm-Schritt, nicht dieser.
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): das Response sieht für jede Email gleich
16
- // aus, egal ob sie schon registriert ist oder nicht. Eine Email KANN bereits
17
- // ein Konto haben (Seeding oder früherer Signup) — die Sperre dagegen sitzt
18
- // bewusst im Confirm-Schritt (#365): signup-confirm lehnt eine bereits
19
- // registrierte Email ab statt den bestehenden User wiederzuverwenden. Hier
20
- // bleibt's always-200 + Resend-idempotent, damit der Request-Pfad nichts
21
- // leakt; ein request-seitiges Unterdrücken des Links wäre Defense-in-depth,
22
- // aber mit Enumeration-Risiko (separat).
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 für den Activation-Token. Default 24 h — lang genug damit User
57
- * "morgen aktivieren" können ohne Resend-Spam. */
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
- readonly appUrl: string;
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-Normalisierung lebt im Store (signup-token-store). Der
105
- // Handler reicht die raw email durch — eine Quelle, kein Drift
106
- // zwischen Lookup-Pfaden die unterschiedlich (oder gar nicht)
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 zu 43 chars. Math.random war früher ein Bug:
115
- // xorshift128+ hat ~128 Bit State der nach ~5 beobachteten
116
- // Outputs rekonstruierbar ist — Angreifer könnte eigene
117
- // signup-requests triggern und die Tokens fremder User
118
- // vorhersagen. generateToken nutzt randomBytes aus node:crypto,
119
- // dieselbe Quelle wie CSRF/Session-Tokens.
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 aus dem Store — eine Quelle für die Normalisierungs-
128
- // Verantwortung; delivery-Empfänger + Lookup-Pfad kriegen konsistent
129
- // das gleiche Format.
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
- ...(opts.locale !== undefined && { locale: opts.locale }),
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). Both follow the same shape:
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
- // App page that receives the magic-link; the handler appends `?token=…`.
74
- readonly appUrl: string;
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
- ...(opts.locale !== undefined && { locale: opts.locale }),
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 → signup-confirm fetch + location.assign", async () => {
61
- const assigned: string[] = [];
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 = ((url: string | URL) => {
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(assigned).toEqual(["/acme/"]);
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;
@@ -76,6 +76,7 @@ export function createLoginRoute(
76
76
  readonly accountLabel: string;
77
77
  } | null>(null);
78
78
 
79
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
79
80
  useEffect(() => {
80
81
  if (status === "authenticated") onAuthenticated?.();
81
82
  }, [status]);
@@ -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]);