@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.
- package/package.json +6 -6
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/feature.ts +8 -1
- package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
- package/src/auth-email-password/index.ts +5 -1
- package/src/auth-email-password/self-registration-toggle.ts +25 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +2 -2
- package/src/auth-email-password/web/auth-gate.tsx +44 -2
- package/src/auth-email-password/web/login-screen.tsx +8 -5
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
- package/src/auth-mfa/constants.ts +2 -0
- package/src/auth-mfa/feature.ts +15 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
- package/src/auth-mfa/mfa-setup-token.ts +7 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
- package/src/auth-mfa/web/i18n.ts +14 -0
- package/src/auth-mfa/web/index.ts +8 -2
- package/src/auth-mfa/web/mfa-client.ts +113 -0
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
- package/src/feature-toggles/compose-tier-resolver.ts +41 -0
- package/src/feature-toggles/index.ts +1 -0
- 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.
|
|
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.
|
|
121
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-renderer-web": "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).
|
|
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.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
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" });
|