@cosmicdrift/kumiko-bundled-features 0.161.0 → 0.162.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 (29) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
  3. package/src/auth-email-password/constants.ts +6 -0
  4. package/src/auth-email-password/feature.ts +8 -1
  5. package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
  6. package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
  7. package/src/auth-email-password/index.ts +5 -1
  8. package/src/auth-email-password/self-registration-toggle.ts +25 -0
  9. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
  10. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +2 -2
  11. package/src/auth-email-password/web/auth-gate.tsx +44 -2
  12. package/src/auth-email-password/web/login-screen.tsx +8 -5
  13. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
  14. package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
  15. package/src/auth-mfa/constants.ts +2 -0
  16. package/src/auth-mfa/feature.ts +15 -0
  17. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
  18. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
  19. package/src/auth-mfa/mfa-setup-token.ts +7 -1
  20. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
  21. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
  22. package/src/auth-mfa/web/i18n.ts +14 -0
  23. package/src/auth-mfa/web/index.ts +8 -2
  24. package/src/auth-mfa/web/mfa-client.ts +113 -0
  25. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
  26. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
  27. package/src/feature-toggles/compose-tier-resolver.ts +41 -0
  28. package/src/feature-toggles/index.ts +1 -0
  29. package/src/feature-toggles/toggle-runtime.ts +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.161.0",
3
+ "version": "0.162.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -117,11 +117,11 @@
117
117
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
118
118
  },
119
119
  "dependencies": {
120
- "@cosmicdrift/kumiko-dispatcher-live": "0.161.0",
121
- "@cosmicdrift/kumiko-framework": "0.161.0",
122
- "@cosmicdrift/kumiko-headless": "0.161.0",
123
- "@cosmicdrift/kumiko-renderer": "0.161.0",
124
- "@cosmicdrift/kumiko-renderer-web": "0.161.0",
120
+ "@cosmicdrift/kumiko-dispatcher-live": "0.162.0",
121
+ "@cosmicdrift/kumiko-framework": "0.162.0",
122
+ "@cosmicdrift/kumiko-headless": "0.162.0",
123
+ "@cosmicdrift/kumiko-renderer": "0.162.0",
124
+ "@cosmicdrift/kumiko-renderer-web": "0.162.0",
125
125
  "@mollie/api-client": "^4.5.0",
126
126
  "imapflow": "^1.3.3",
127
127
  "mailparser": "^3.9.8",
@@ -0,0 +1,168 @@
1
+ // Runtime on/off switch for self-signup (#self-registration-toggle). Pins:
2
+ // 1. Default (no override row) → signup-request works.
3
+ // 2. runtime.apply(off) → signup-request 403 feature_disabled, no mail sent.
4
+ // 3. runtime.apply(on) → works again.
5
+ // Mirrors samples/recipes/feature-toggles' runtime.apply() pattern — flips the
6
+ // in-memory snapshot directly instead of going through the set-handler/HTTP,
7
+ // since that wiring is already covered by feature-toggles' own tests.
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import {
11
+ createFeatureTogglesFeature,
12
+ GlobalFeatureToggleRuntime,
13
+ globalFeatureStateTable,
14
+ } from "@cosmicdrift/kumiko-bundled-features/feature-toggles";
15
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
17
+ import {
18
+ setupTestStack,
19
+ type TestStack,
20
+ unsafeCreateEntityTable,
21
+ unsafePushTables,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
24
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
25
+ import { createConfigFeature } from "../../config";
26
+ import { createConfigResolver } from "../../config/resolver";
27
+ import { configValuesTable } from "../../config/table";
28
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
29
+ import { notificationPreferencesTable } from "../../delivery/tables";
30
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
31
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
32
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
33
+ import { createTenantFeature } from "../../tenant";
34
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
35
+ import { tenantEntity } from "../../tenant/schema/tenant";
36
+ import { createUserFeature } from "../../user/feature";
37
+ import { userEntity, userTable } from "../../user/schema/user";
38
+ import { AuthHandlers } from "../constants";
39
+ import { createAuthEmailPasswordFeature } from "../feature";
40
+ import {
41
+ AUTH_SELF_REGISTRATION_FEATURE,
42
+ createAuthSelfRegistrationToggleFeature,
43
+ } from "../self-registration-toggle";
44
+
45
+ const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
46
+ const emailTransport = createInMemoryTransport();
47
+
48
+ let stack: TestStack;
49
+ let runtime: GlobalFeatureToggleRuntime;
50
+
51
+ beforeAll(async () => {
52
+ let effective: () => ReadonlySet<string> = () => new Set();
53
+ const runtimeHolder = createLateBoundHolder<GlobalFeatureToggleRuntime>("runtime");
54
+
55
+ stack = await setupTestStack({
56
+ features: [
57
+ createConfigFeature(),
58
+ createUserFeature(),
59
+ createTenantFeature(),
60
+ createTemplateResolverFeature(),
61
+ createRendererFoundationFeature(),
62
+ createDeliveryFeature(),
63
+ createRendererSimpleFeature(),
64
+ createChannelEmailFeature({
65
+ transport: emailTransport,
66
+ renderer: simpleRenderer,
67
+ resolveEmail: async () => "unused@test.local",
68
+ }),
69
+ createAuthEmailPasswordFeature({
70
+ signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
71
+ }),
72
+ createAuthSelfRegistrationToggleFeature(),
73
+ createFeatureTogglesFeature({ getRuntime: () => runtimeHolder.get() }),
74
+ ],
75
+ effectiveFeatures: () => effective(),
76
+ extraContext: (deps) => ({
77
+ ...createDeliveryTestContext(deps),
78
+ configResolver: createConfigResolver(),
79
+ }),
80
+ systemHooks: [],
81
+ anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
82
+ authConfig: {
83
+ membershipQuery: "tenant:query:memberships",
84
+ loginHandler: AuthHandlers.login,
85
+ signup: {
86
+ requestHandler: AuthHandlers.signupRequest,
87
+ confirmHandler: AuthHandlers.signupConfirm,
88
+ },
89
+ },
90
+ });
91
+
92
+ await unsafeCreateEntityTable(stack.db, userEntity);
93
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
94
+ await unsafePushTables(stack.db, {
95
+ configValuesTable,
96
+ tenantMembershipsTable,
97
+ notificationPreferencesTable,
98
+ globalFeatureStateTable,
99
+ });
100
+
101
+ runtime = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
102
+ await runtime.initialize();
103
+ effective = runtime.effectiveFeatures;
104
+ runtimeHolder.set(runtime);
105
+ });
106
+
107
+ afterAll(async () => {
108
+ await stack.cleanup();
109
+ });
110
+
111
+ beforeEach(async () => {
112
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
113
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${globalFeatureStateTable.tableName}"`);
114
+ await runtime.refresh();
115
+ emailTransport.sent.length = 0;
116
+ const allKeys = await stack.redis.redis.keys("signup:*");
117
+ if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
118
+ });
119
+
120
+ async function postSignupRequest(email: string): Promise<Response> {
121
+ return stack.http.raw("POST", "/api/auth/signup-request", { email });
122
+ }
123
+
124
+ describe("auth-self-registration toggle", () => {
125
+ test("default (no override) → signup-request works", async () => {
126
+ const res = await postSignupRequest("alice@example.com");
127
+ expect(res.status).toBe(200);
128
+ expect(emailTransport.sent).toHaveLength(1);
129
+ });
130
+
131
+ test("runtime off → signup-request still 200 (anti-enumeration silent-success) but no mail sent", async () => {
132
+ runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, false);
133
+
134
+ const res = await postSignupRequest("blocked@example.com");
135
+ expect(res.status).toBe(200);
136
+ expect(emailTransport.sent).toHaveLength(0);
137
+ });
138
+
139
+ test("runtime back on → mail sends again", async () => {
140
+ runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, false);
141
+ await postSignupRequest("still-blocked@example.com");
142
+ expect(emailTransport.sent).toHaveLength(0);
143
+
144
+ runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, true);
145
+ const res = await postSignupRequest("reenabled@example.com");
146
+ expect(res.status).toBe(200);
147
+ expect(emailTransport.sent).toHaveLength(1);
148
+ });
149
+
150
+ test("status query reflects the runtime flip", async () => {
151
+ const onRes = await stack.http.raw("POST", "/api/query", {
152
+ type: "auth-email-password:query:signup-registration-status",
153
+ payload: {},
154
+ });
155
+ expect((await onRes.json()) as { data?: { enabled: boolean } }).toMatchObject({
156
+ data: { enabled: true },
157
+ });
158
+
159
+ runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, false);
160
+ const offRes = await stack.http.raw("POST", "/api/query", {
161
+ type: "auth-email-password:query:signup-registration-status",
162
+ payload: {},
163
+ });
164
+ expect((await offRes.json()) as { data?: { enabled: boolean } }).toMatchObject({
165
+ data: { enabled: false },
166
+ });
167
+ });
168
+ });
@@ -46,6 +46,12 @@ export const AuthHandlers = {
46
46
  inviteCancel: "auth-email-password:write:invite-cancel",
47
47
  } as const;
48
48
 
49
+ // Qualified query names. Anonymous-readable status so the (unauthenticated)
50
+ // signup page can decide whether to show its own link/form.
51
+ export const AuthQueries = {
52
+ signupRegistrationStatus: "auth-email-password:query:signup-registration-status",
53
+ } as const;
54
+
49
55
  // Error codes — kept intentionally generic so clients can't distinguish
50
56
  // "email doesn't exist" from "password wrong". Both surface as invalid_credentials.
51
57
  // Soft-deleted users also collapse into invalid_credentials to avoid enumeration.
@@ -20,6 +20,7 @@ import {
20
20
  import { createRequestEmailVerificationHandler } from "./handlers/request-email-verification.write";
21
21
  import { createRequestPasswordResetHandler } from "./handlers/request-password-reset.write";
22
22
  import { createResetPasswordHandler } from "./handlers/reset-password.write";
23
+ import { selfRegistrationStatusQuery } from "./handlers/self-registration-status.query";
23
24
  import { createSignupConfirmHandler } from "./handlers/signup-confirm.write";
24
25
  import {
25
26
  createSignupRequestHandler,
@@ -223,6 +224,12 @@ export function createAuthEmailPasswordFeature(
223
224
  r.writeHandler(createVerifyEmailHandler(opts.emailVerification));
224
225
  }
225
226
 
227
+ const queries = {
228
+ ...(opts.signup && {
229
+ signupRegistrationStatus: r.queryHandler(selfRegistrationStatusQuery),
230
+ }),
231
+ };
232
+
226
233
  if (opts.signup) {
227
234
  r.writeHandler(createSignupRequestHandler(opts.signup));
228
235
  r.writeHandler(createSignupConfirmHandler());
@@ -240,6 +247,6 @@ export function createAuthEmailPasswordFeature(
240
247
  r.writeHandler(createConfirmAccountUnlockHandler(opts.accountUnlock));
241
248
  }
242
249
 
243
- return { handlers };
250
+ return { handlers, queries };
244
251
  });
245
252
  }
@@ -0,0 +1,20 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { z } from "zod";
3
+ import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
4
+
5
+ // Anonymous-readable status for the (unauthenticated) signup page: lets it
6
+ // hide its own link/form when an operator has flipped self-registration
7
+ // off, instead of collecting an email that signup-request will silently
8
+ // no-op on. Lives in auth-email-password itself (never toggleable) rather
9
+ // than on the auth-self-registration companion feature — the dispatcher's
10
+ // per-feature gate would otherwise make this query unreachable exactly when
11
+ // the toggle is off (same split as managed-pages' branding query vs.
12
+ // css-gate.ts).
13
+ export const selfRegistrationStatusQuery = defineQueryHandler({
14
+ name: "signup-registration-status",
15
+ schema: z.object({}),
16
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
17
+ handler: async (_query, ctx) => ({
18
+ enabled: await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE),
19
+ }),
20
+ });
@@ -31,6 +31,7 @@ import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
31
31
  import type { AuthMailLocale } from "../email-templates";
32
32
  import { renderActivationEmail } from "../email-templates";
33
33
  import { dispatchMagicLinkMail } from "../magic-link-mail";
34
+ import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
34
35
  import { getTokenForSignupEmail, normalizeEmail, storeSignupToken } from "../signup-token-store";
35
36
 
36
37
  const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
@@ -68,6 +69,16 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
68
69
  schema: SignupRequestSchema,
69
70
  access: { roles: ["all"] },
70
71
  handler: async (event, ctx) => {
72
+ // Silent no-op when off, matching the route's own always-200
73
+ // anti-enumeration contract (registerTokenRequestRoute swallows every
74
+ // handler failure into `{isSuccess:true}` regardless) — no mail goes
75
+ // out, but the caller can't distinguish "disabled" from "unknown
76
+ // email" either way. The client-visible signal is the `status` query
77
+ // on auth-self-registration, which the signup page uses to hide its
78
+ // own link/form instead of collecting input that silently no-ops.
79
+ if (!(await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE))) {
80
+ return { isSuccess: true, data: { kind: "no-op" } };
81
+ }
71
82
  if (!ctx.redis) {
72
83
  return writeFailure(
73
84
  new InternalError({
@@ -7,7 +7,7 @@
7
7
  // its own changeset + deliberate deprecation window, not a silent drop.
8
8
  export { hashPassword, verifyPassword } from "../shared/password-hashing";
9
9
  export { type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths";
10
- export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
10
+ export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants";
11
11
  // Renderers for the auth mails. All four magic-link flows (reset, verify,
12
12
  // signup-activation, invite) emit structured AuthMailContent through delivery
13
13
  // (ctx.notify).
@@ -35,6 +35,10 @@ export type {
35
35
  SignupOptions,
36
36
  } from "./feature";
37
37
  export { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "./feature";
38
+ export {
39
+ AUTH_SELF_REGISTRATION_FEATURE,
40
+ createAuthSelfRegistrationToggleFeature,
41
+ } from "./self-registration-toggle";
38
42
  // Generic HMAC-signed single-purpose token helpers. Re-exported damit
39
43
  // app-spezifische out-of-band-Flows (subscriber-confirm, magic-links,
40
44
  // invite-tokens) denselben battle-tested signer/verifier nutzen können
@@ -0,0 +1,25 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // Companion toggle for the auth-email-password self-signup flow. Handler-less
4
+ // (like managed-pages-css/css-gate.ts): composing it just registers
5
+ // "auth-self-registration" as toggleable (default ON) so an operator can
6
+ // flip it at runtime via feature-toggles, without redeploying and without
7
+ // making the rest of auth-email-password (login/reset/verify) toggleable.
8
+ //
9
+ // Deliberately owns NO handlers — the dispatcher's per-feature gate blocks
10
+ // every handler belonging to a disabled feature, and a `status` query living
11
+ // here would become unreachable exactly when it matters most (disabled).
12
+ // The signup-request handler and the `signupRegistrationStatus` query both
13
+ // live in auth-email-password itself and read `ctx.hasFeature(...)` against
14
+ // this feature name instead — same split as managed-pages/css-gate.ts vs.
15
+ // managed-pages' branding query.
16
+ export const AUTH_SELF_REGISTRATION_FEATURE = "auth-self-registration";
17
+
18
+ export function createAuthSelfRegistrationToggleFeature(): FeatureDefinition {
19
+ return defineFeature(AUTH_SELF_REGISTRATION_FEATURE, (r) => {
20
+ r.describe(
21
+ 'Runtime on/off switch for the auth-email-password self-signup flow. Handler-less: composing it registers "auth-self-registration" as a toggleable feature (default ON). auth-email-password\'s signup-request handler and its `signupRegistrationStatus` query both read `ctx.hasFeature("auth-self-registration")` — the query stays reachable when the toggle is off (deliberately not gated itself) so the public signup page can hide its own link/form. Only meaningful when `signup` is configured on `createAuthEmailPasswordFeature` — compose alongside it, then flip via the feature-toggles admin screen.',
22
+ );
23
+ r.toggleable({ default: true });
24
+ });
25
+ }
@@ -70,6 +70,43 @@ describe("createLoginRoute", () => {
70
70
  return <div data-testid="mfa-verify">{challengeToken}</div>;
71
71
  }
72
72
 
73
+ function LoginWithMfaSetupTrigger({
74
+ onMfaSetupRequired,
75
+ }: {
76
+ readonly onMfaSetupRequired?: (preauthSetupToken: string, accountLabel: string) => void;
77
+ }): ReactNode {
78
+ return (
79
+ <button
80
+ type="button"
81
+ data-testid="trigger-mfa-setup"
82
+ onClick={() => onMfaSetupRequired?.("setup-token-123", "user@example.com")}
83
+ >
84
+ trigger
85
+ </button>
86
+ );
87
+ }
88
+
89
+ function CustomMfaSetup({
90
+ preauthSetupToken,
91
+ accountLabel,
92
+ onSuccess,
93
+ }: {
94
+ readonly preauthSetupToken: string;
95
+ readonly accountLabel: string;
96
+ readonly onSuccess?: () => void;
97
+ }): ReactNode {
98
+ return (
99
+ <div data-testid="mfa-setup">
100
+ <span data-testid="mfa-setup-info">
101
+ {preauthSetupToken}:{accountLabel}
102
+ </span>
103
+ <button type="button" data-testid="complete-mfa-setup" onClick={() => onSuccess?.()}>
104
+ complete
105
+ </button>
106
+ </div>
107
+ );
108
+ }
109
+
73
110
  test("authenticated + onAuthenticated → renders nothing, fires onAuthenticated exactly once", () => {
74
111
  const onAuthenticated = mock(() => {});
75
112
  const LoginRoute = createLoginRoute({ loginScreen: CustomLogin, onAuthenticated });
@@ -109,4 +146,46 @@ describe("createLoginRoute", () => {
109
146
  fireEvent.click(screen.getByTestId("trigger-mfa"));
110
147
  expect(screen.getByTestId("mfa-verify").textContent).toBe("token-123");
111
148
  });
149
+
150
+ test("onMfaSetupRequired → renders MfaSetupComponent with token and accountLabel", () => {
151
+ const LoginRoute = createLoginRoute({
152
+ loginScreen: LoginWithMfaSetupTrigger,
153
+ mfaSetupScreen: CustomMfaSetup,
154
+ });
155
+ const session = makeSessionApi({ status: "unauthenticated" });
156
+ renderWithProviders(<LoginRoute />, { session });
157
+ fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
158
+ expect(screen.getByTestId("mfa-setup-info").textContent).toBe(
159
+ "setup-token-123:user@example.com",
160
+ );
161
+ });
162
+
163
+ test("MfaSetupComponent onSuccess → gate clears the request and refreshes the session", () => {
164
+ const LoginRoute = createLoginRoute({
165
+ loginScreen: LoginWithMfaSetupTrigger,
166
+ mfaSetupScreen: CustomMfaSetup,
167
+ });
168
+ const session = makeSessionApi({ status: "unauthenticated" });
169
+ renderWithProviders(<LoginRoute />, { session });
170
+ fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
171
+ expect(screen.getByTestId("mfa-setup")).toBeTruthy();
172
+ fireEvent.click(screen.getByTestId("complete-mfa-setup"));
173
+ expect(session.refresh).toHaveBeenCalledTimes(1);
174
+ expect(screen.queryByTestId("mfa-setup")).toBeNull();
175
+ });
176
+
177
+ test("makeAuthGate delegates mfaSetupScreen wiring to createLoginRoute", () => {
178
+ const Gate = makeAuthGate(LoginWithMfaSetupTrigger, undefined, undefined, CustomMfaSetup);
179
+ const session = makeSessionApi({ status: "unauthenticated" });
180
+ renderWithProviders(
181
+ <Gate>
182
+ <div data-testid="protected">secret</div>
183
+ </Gate>,
184
+ { session },
185
+ );
186
+ fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
187
+ expect(screen.getByTestId("mfa-setup-info").textContent).toBe(
188
+ "setup-token-123:user@example.com",
189
+ );
190
+ });
112
191
  });
@@ -336,7 +336,7 @@ describe("LoginScreen", () => {
336
336
  });
337
337
 
338
338
  test("mfa-setup-required with and without onMfaSetupRequired", async () => {
339
- const onMfaSetupRequired = mock<() => void>();
339
+ const onMfaSetupRequired = mock<(preauthSetupToken: string, accountLabel: string) => void>();
340
340
  const sessionOk = makeSessionApi({
341
341
  status: "unauthenticated",
342
342
  user: null,
@@ -353,7 +353,7 @@ describe("LoginScreen", () => {
353
353
  fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
354
354
  fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
355
355
  await waitFor(() => {
356
- expect(onMfaSetupRequired).toHaveBeenCalled();
356
+ expect(onMfaSetupRequired).toHaveBeenCalledWith("setup-token-value", "a@b.c");
357
357
  });
358
358
  unmount();
359
359
 
@@ -23,10 +23,21 @@ export type MfaVerifyComponentProps = {
23
23
  readonly onCancel?: () => void;
24
24
  };
25
25
 
26
+ // Same coupling reasoning as MfaVerifyComponentProps above — generic, not
27
+ // auth-mfa's MfaSetupPreauthScreenProps directly. Apps wire auth-mfa's
28
+ // MfaSetupPreauthScreen in here via EmailPasswordClientOptions.
29
+ export type MfaSetupComponentProps = {
30
+ readonly preauthSetupToken: string;
31
+ readonly accountLabel: string;
32
+ readonly onSuccess?: () => void;
33
+ readonly onCancel?: () => void;
34
+ };
35
+
26
36
  export type LoginRouteOptions = {
27
37
  readonly loginScreen?: ComponentType<LoginScreenProps>;
28
38
  readonly loginScreenProps?: LoginScreenProps;
29
39
  readonly mfaVerifyScreen?: ComponentType<MfaVerifyComponentProps>;
40
+ readonly mfaSetupScreen?: ComponentType<MfaSetupComponentProps>;
30
41
  /** Called once the session becomes authenticated. makeAuthGate ignores
31
42
  * this (it renders `children` on its own authenticated branch instead);
32
43
  * standalone routes — no parent gate, e.g. an anonymous apex/marketing
@@ -51,14 +62,19 @@ export function createLoginRoute(
51
62
  ): ComponentType<Record<string, never>> {
52
63
  const LoginComponent = opts.loginScreen ?? LoginScreen;
53
64
  const MfaVerifyComponent = opts.mfaVerifyScreen;
65
+ const MfaSetupComponent = opts.mfaSetupScreen;
54
66
 
55
67
  function LoginRoute(): ReactNode {
56
- const { status } = useSession();
68
+ const { status, refresh } = useSession();
57
69
  const { onAuthenticated } = opts;
58
70
  // Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
59
71
  // (not in SessionState) because it's a UI-only transition — the server
60
72
  // never considers this session authenticated until verify succeeds.
61
73
  const [challengeToken, setChallengeToken] = useState<string | null>(null);
74
+ const [setupRequest, setSetupRequest] = useState<{
75
+ readonly preauthSetupToken: string;
76
+ readonly accountLabel: string;
77
+ } | null>(null);
62
78
 
63
79
  useEffect(() => {
64
80
  if (status === "authenticated") onAuthenticated?.();
@@ -85,12 +101,35 @@ export function createLoginRoute(
85
101
  />
86
102
  );
87
103
  }
104
+ // Pending preauthSetupToken from LoginScreen's onMfaSetupRequired. Same
105
+ // reasoning as challengeToken — a UI-only transition, not session state.
106
+ if (setupRequest !== null && MfaSetupComponent) {
107
+ return (
108
+ <MfaSetupComponent
109
+ preauthSetupToken={setupRequest.preauthSetupToken}
110
+ accountLabel={setupRequest.accountLabel}
111
+ onSuccess={() => {
112
+ setSetupRequest(null);
113
+ // MfaSetupPreauthScreen has no session to refresh itself with
114
+ // (it runs pre-auth) — the gate owns the session, so it refreshes.
115
+ void refresh();
116
+ }}
117
+ onCancel={() => setSetupRequest(null)}
118
+ />
119
+ );
120
+ }
88
121
  return (
89
122
  <LoginComponent
90
123
  {...opts.loginScreenProps}
91
124
  onMfaChallenge={
92
125
  MfaVerifyComponent ? setChallengeToken : opts.loginScreenProps?.onMfaChallenge
93
126
  }
127
+ onMfaSetupRequired={
128
+ MfaSetupComponent
129
+ ? (preauthSetupToken, accountLabel) =>
130
+ setSetupRequest({ preauthSetupToken, accountLabel })
131
+ : opts.loginScreenProps?.onMfaSetupRequired
132
+ }
94
133
  />
95
134
  );
96
135
  }
@@ -101,11 +140,13 @@ export function makeAuthGate(
101
140
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
102
141
  loginProps?: LoginScreenProps,
103
142
  MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
143
+ MfaSetupComponent?: ComponentType<MfaSetupComponentProps>,
104
144
  ): ComponentType<{ children: ReactNode }> {
105
145
  const LoginRoute = createLoginRoute({
106
146
  loginScreen: LoginComponent,
107
147
  loginScreenProps: loginProps,
108
148
  mfaVerifyScreen: MfaVerifyComponent,
149
+ mfaSetupScreen: MfaSetupComponent,
109
150
  });
110
151
  function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
111
152
  const { status } = useSession();
@@ -122,8 +163,9 @@ export function makeSessionAuthGate(
122
163
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
123
164
  loginProps?: LoginScreenProps,
124
165
  MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
166
+ MfaSetupComponent?: ComponentType<MfaSetupComponentProps>,
125
167
  ): ComponentType<{ children: ReactNode }> {
126
- const AuthGate = makeAuthGate(LoginComponent, loginProps, MfaVerifyComponent);
168
+ const AuthGate = makeAuthGate(LoginComponent, loginProps, MfaVerifyComponent, MfaSetupComponent);
127
169
  function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
128
170
  return (
129
171
  <SessionProvider>
@@ -64,10 +64,13 @@ export type LoginScreenProps = {
64
64
  * hang, but apps mounting auth-mfa must wire this. */
65
65
  readonly onMfaChallenge?: (challengeToken: string) => void;
66
66
  /** Called when the tenant's enforcement policy requires MFA but this
67
- * user has no factor enrolled yet. Apps typically route to an
68
- * enrollment flow. Without a handler, the user sees a "setup required,
69
- * contact your administrator" error. */
70
- readonly onMfaSetupRequired?: () => void;
67
+ * user has no factor enrolled yet. Carries the preauthSetupToken the
68
+ * login response issued plus the typed email (no session to derive it
69
+ * from) apps typically swap this screen out for auth-mfa's
70
+ * MfaSetupPreauthScreen(preauthSetupToken, accountLabel). Without a
71
+ * handler, the user sees a "setup required, contact your administrator"
72
+ * error. */
73
+ readonly onMfaSetupRequired?: (preauthSetupToken: string, accountLabel: string) => void;
71
74
  };
72
75
 
73
76
  // Map vom Reason-Code des Login-Handlers auf einen i18n-Key plus
@@ -147,7 +150,7 @@ export function LoginScreen({
147
150
  }
148
151
  if (res.kind === "mfa-setup-required") {
149
152
  if (onMfaSetupRequired) {
150
- onMfaSetupRequired();
153
+ onMfaSetupRequired(res.preauthSetupToken, email);
151
154
  return;
152
155
  }
153
156
  setError({ reason: "mfa_setup_required" });