@cosmicdrift/kumiko-bundled-features 0.256.0 → 0.258.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 (59) hide show
  1. package/package.json +9 -10
  2. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +78 -6
  3. package/src/auth-mfa/__tests__/screen.test.ts +58 -0
  4. package/src/auth-mfa/feature.ts +6 -16
  5. package/src/auth-mfa/handlers/enable-start.write.ts +21 -12
  6. package/src/auth-mfa/i18n.ts +19 -0
  7. package/src/auth-mfa/screens.ts +52 -0
  8. package/src/auth-mfa/web/client-plugin.ts +4 -5
  9. package/src/auth-mfa/web/i18n.ts +0 -7
  10. package/src/auth-mfa/web/index.ts +0 -2
  11. package/src/auth-mfa/web/mfa-disable-dialog.tsx +2 -2
  12. package/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +4 -4
  13. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +5 -5
  14. package/src/auth-mfa/web/qrcode-browser.d.ts +2 -2
  15. package/src/folders/__tests__/folders-read-gate.integration.test.ts +179 -0
  16. package/src/folders/entity.ts +47 -28
  17. package/src/folders/feature.ts +58 -4
  18. package/src/folders/handlers/clear-folder.write.ts +4 -4
  19. package/src/folders/handlers/set-folder.write.ts +4 -4
  20. package/src/ledger/__tests__/ledger.integration.test.ts +111 -4
  21. package/src/ledger/entity.ts +30 -0
  22. package/src/ledger/handlers/confirm-schedule-period.write.ts +7 -0
  23. package/src/ledger/handlers/create-transaction.write.ts +2 -0
  24. package/src/ledger/schemas.ts +3 -0
  25. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +2 -1
  26. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +4 -1
  27. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +3 -1
  28. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  29. package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +243 -0
  30. package/src/notes-history/entity.ts +11 -1
  31. package/src/notes-history/feature.ts +12 -11
  32. package/src/notes-history/handlers/add-note.write.ts +8 -12
  33. package/src/personal-access-tokens/__tests__/expiry.test.ts +19 -0
  34. package/src/personal-access-tokens/__tests__/feature.test.ts +93 -0
  35. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +143 -13
  36. package/src/personal-access-tokens/constants.ts +5 -2
  37. package/src/personal-access-tokens/expiry.ts +7 -0
  38. package/src/personal-access-tokens/feature.ts +8 -17
  39. package/src/personal-access-tokens/handlers/list.query.ts +75 -16
  40. package/src/personal-access-tokens/i18n.ts +54 -0
  41. package/src/personal-access-tokens/resolver.ts +2 -7
  42. package/src/personal-access-tokens/screens.ts +107 -0
  43. package/src/shared/index.ts +1 -1
  44. package/src/shared/parent-visibility.ts +30 -0
  45. package/src/tags/__tests__/tags-read-gate.integration.test.ts +366 -0
  46. package/src/tags/__tests__/tags.integration.test.ts +1 -0
  47. package/src/tags/entity.ts +11 -1
  48. package/src/tags/feature.ts +25 -8
  49. package/src/tags/handlers/assign-tag.write.ts +2 -8
  50. package/src/tags/handlers/remove-tag.write.ts +2 -8
  51. package/src/tags/web/__tests__/entity-tags.test.tsx +13 -4
  52. package/src/tags/web/entity-tags.tsx +9 -2
  53. package/src/tags/web/tag-section.tsx +9 -4
  54. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +0 -182
  55. package/src/auth-mfa/web/mfa-enable-screen.tsx +0 -224
  56. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  57. package/src/personal-access-tokens/web/i18n.ts +0 -40
  58. package/src/personal-access-tokens/web/index.ts +0 -7
  59. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -296
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.256.0",
3
+ "version": "0.258.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>",
@@ -110,7 +110,6 @@
110
110
  "./auth-mfa/testing": "./src/auth-mfa/testing.ts",
111
111
  "./sessions/testing": "./src/sessions/testing.ts",
112
112
  "./personal-access-tokens": "./src/personal-access-tokens/index.ts",
113
- "./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
114
113
  "./feature-toggles": "./src/feature-toggles/index.ts",
115
114
  "./feature-toggles/web": "./src/feature-toggles/web/index.ts",
116
115
  "./template-resolver": "./src/template-resolver/index.ts",
@@ -130,12 +129,12 @@
130
129
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
130
  },
132
131
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.256.0",
134
- "@cosmicdrift/kumiko-framework": "0.256.0",
135
- "@cosmicdrift/kumiko-headless": "0.256.0",
136
- "@cosmicdrift/kumiko-renderer": "0.256.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.256.0",
138
- "@cosmicdrift/kumiko-types": "0.256.0",
132
+ "@cosmicdrift/kumiko-dispatcher-live": "0.258.0",
133
+ "@cosmicdrift/kumiko-framework": "0.258.0",
134
+ "@cosmicdrift/kumiko-headless": "0.258.0",
135
+ "@cosmicdrift/kumiko-renderer": "0.258.0",
136
+ "@cosmicdrift/kumiko-renderer-web": "0.258.0",
137
+ "@cosmicdrift/kumiko-types": "0.258.0",
139
138
  "@mollie/api-client": "^4.5.0",
140
139
  "@node-rs/argon2": "^2.0.2",
141
140
  "@types/mailparser": "^3.4.6",
@@ -164,7 +163,7 @@
164
163
  "devDependencies": {
165
164
  "@testing-library/user-event": "^14.6.1",
166
165
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.256.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.256.0"
166
+ "@cosmicdrift/kumiko-locale-de": "0.258.0",
167
+ "@cosmicdrift/kumiko-locale-es": "0.258.0"
169
168
  }
170
169
  }
@@ -4,6 +4,7 @@ import {
4
4
  createTestUser,
5
5
  setupTestStack,
6
6
  type TestStack,
7
+ TestUsers,
7
8
  unsafeCreateEntityTable,
8
9
  unsafePushTables,
9
10
  } from "@cosmicdrift/kumiko-framework/stack";
@@ -15,6 +16,7 @@ import { createConfigFeature } from "../../config";
15
16
  import { createConfigResolver } from "../../config/resolver";
16
17
  import { configValuesTable } from "../../config/table";
17
18
  import { createTenantFeature } from "../../tenant";
19
+ import { UserHandlers } from "../../user/constants";
18
20
  import { createUserFeature } from "../../user/feature";
19
21
  import { userEntity } from "../../user/schema/user";
20
22
  import { base32Decode } from "../base32";
@@ -56,12 +58,13 @@ afterAll(async () => {
56
58
  });
57
59
 
58
60
  describe("enable-start + enable-confirm round trip", () => {
59
- test("start returns a setup token + otpauth URI + 8 recovery codes", async () => {
61
+ test("start returns a setup token + otpauth URI + totp secret + 8 recovery codes", async () => {
60
62
  const user = createTestUser({ id: "enable-start-1", roles: ["User"] });
61
63
 
62
64
  const res = await stack.http.writeOk<{
63
65
  setupToken: string;
64
66
  otpauthUri: string;
67
+ totpSecret: string;
65
68
  recoveryCodes: string[];
66
69
  }>(AuthMfaHandlers.enableStart, { accountLabel: "jane@example.com" }, user);
67
70
 
@@ -73,6 +76,30 @@ describe("enable-start + enable-confirm round trip", () => {
73
76
  for (const code of res.recoveryCodes) {
74
77
  expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);
75
78
  }
79
+ // The manual-entry secret is the same one embedded in the otpauth URI —
80
+ // it's a convenience duplicate for the reveal screen, not new exposure.
81
+ const secretParam = new URLSearchParams(res.otpauthUri.split("?")[1]).get("secret") ?? "";
82
+ expect(res.totpSecret).toBe(secretParam);
83
+ });
84
+
85
+ test("accountLabel is optional — omitted, the caller's own email is derived server-side", async () => {
86
+ const created = await stack.http.writeOk<{ id: string }>(
87
+ UserHandlers.create,
88
+ {
89
+ email: "derived-label@example.com",
90
+ displayName: "Derived Label",
91
+ passwordHash: "seeded-hash",
92
+ },
93
+ TestUsers.systemAdmin,
94
+ );
95
+ const user = createTestUser({ id: created.id, roles: ["User"] });
96
+
97
+ const res = await stack.http.writeOk<{ otpauthUri: string }>(
98
+ AuthMfaHandlers.enableStart,
99
+ {},
100
+ user,
101
+ );
102
+ expect(res.otpauthUri).toContain(encodeURIComponent("derived-label@example.com"));
76
103
  });
77
104
 
78
105
  test("confirm with the right code enables MFA", async () => {
@@ -150,11 +177,16 @@ describe("enable-start + enable-confirm round trip", () => {
150
177
  expectErrorIncludes(err, "invalid_setup_token");
151
178
  });
152
179
 
153
- test("status query reflects enrollment before and after enable-confirm", async () => {
180
+ test("status query reflects enrollment before and after enable-confirm, and never carries the secret or codes", async () => {
154
181
  const user = createTestUser({ id: "status-query-1", roles: ["User"] });
155
182
 
156
- const before = await stack.http.queryOk<{ enabled: boolean }>(AuthMfaQueries.status, {}, user);
157
- expect(before.enabled).toBe(false);
183
+ const before = await stack.http.queryOk<Record<string, unknown>>(
184
+ AuthMfaQueries.status,
185
+ {},
186
+ user,
187
+ );
188
+ expect(before["enabled"]).toBe(false);
189
+ expect(Object.keys(before)).toEqual(["enabled"]);
158
190
 
159
191
  const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
160
192
  AuthMfaHandlers.enableStart,
@@ -169,8 +201,48 @@ describe("enable-start + enable-confirm round trip", () => {
169
201
  user,
170
202
  );
171
203
 
172
- const after = await stack.http.queryOk<{ enabled: boolean }>(AuthMfaQueries.status, {}, user);
173
- expect(after.enabled).toBe(true);
204
+ const after = await stack.http.queryOk<Record<string, unknown>>(
205
+ AuthMfaQueries.status,
206
+ {},
207
+ user,
208
+ );
209
+ expect(after["enabled"]).toBe(true);
210
+ // The only read surface for user-mfa (AuthMfaQueries has no other entry) —
211
+ // once enrolled it must never re-serve the plaintext recovery codes or the
212
+ // TOTP secret shown once at enable-start.
213
+ expect(Object.keys(after)).toEqual(["enabled"]);
214
+ expect(after["totpSecret"]).toBeUndefined();
215
+ expect(after["recoveryCodes"]).toBeUndefined();
216
+ });
217
+ });
218
+
219
+ describe("aborting between enable-start and enable-confirm leaves a clean state", () => {
220
+ test("MFA stays off and no user-mfa row exists until a confirm succeeds; a fresh start reissues codes", async () => {
221
+ const user = createTestUser({ id: "abort-mid-flow-1", roles: ["User"] });
222
+
223
+ const first = await stack.http.writeOk<{ setupToken: string; recoveryCodes: string[] }>(
224
+ AuthMfaHandlers.enableStart,
225
+ { accountLabel: "abort@example.com" },
226
+ user,
227
+ );
228
+
229
+ // enable-start alone never persists anything — status still reads "off".
230
+ const statusAfterAbort = await stack.http.queryOk<{ enabled: boolean }>(
231
+ AuthMfaQueries.status,
232
+ {},
233
+ user,
234
+ );
235
+ expect(statusAfterAbort.enabled).toBe(false);
236
+
237
+ // The user can restart the flow and gets an independent token + codes —
238
+ // no leftover state from the abandoned attempt blocks or reuses anything.
239
+ const second = await stack.http.writeOk<{ setupToken: string; recoveryCodes: string[] }>(
240
+ AuthMfaHandlers.enableStart,
241
+ { accountLabel: "abort@example.com" },
242
+ user,
243
+ );
244
+ expect(second.setupToken).not.toBe(first.setupToken);
245
+ expect(second.recoveryCodes).not.toEqual(first.recoveryCodes);
174
246
  });
175
247
  });
176
248
 
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
3
+ import { AuthMfaHandlers, MFA_ENABLE_SCREEN_ID } from "../constants";
4
+ import { createAuthMfaFeature } from "../feature";
5
+ import { AUTH_MFA_FEATURE_I18N } from "../i18n";
6
+ import { mfaEnableScreen } from "../screens";
7
+
8
+ const feature = createAuthMfaFeature({
9
+ setupTokenSecret: "test-setup-token-secret-do-not-use-in-prod",
10
+ issuer: "Kumiko Test",
11
+ challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
12
+ });
13
+
14
+ describe("auth-mfa enable screen — declarative secretMint (fw#2838)", () => {
15
+ test("is registered as a secretMint, not a custom screen", () => {
16
+ const screen = feature.screens[MFA_ENABLE_SCREEN_ID];
17
+ expect(screen?.type).toBe("secretMint");
18
+ if (screen?.type !== "secretMint") throw new Error("expected a secretMint screen");
19
+ expect(screen.handler).toBe(AuthMfaHandlers.enableStart);
20
+ });
21
+
22
+ test("reveal.fields is exactly otpauthUri, totpSecret, recoveryCodes — never setupToken", () => {
23
+ const fields = mfaEnableScreen.reveal.fields.map((f) => f.field);
24
+ expect(fields).toEqual(["otpauthUri", "totpSecret", "recoveryCodes"]);
25
+ expect(fields).not.toContain("setupToken");
26
+ });
27
+
28
+ test("otpauthUri renders as a QR code and is not copyable; secret and codes are copyable", () => {
29
+ const byField = Object.fromEntries(mfaEnableScreen.reveal.fields.map((f) => [f.field, f]));
30
+ expect(byField["otpauthUri"]).toMatchObject({ display: "qr", copyable: false });
31
+ expect(byField["totpSecret"]).toMatchObject({ display: "code", copyable: true });
32
+ expect(byField["recoveryCodes"]).toMatchObject({ display: "list", copyable: true });
33
+ });
34
+
35
+ test("confirm carries exactly setupToken from the mint payload into the code-confirm step", () => {
36
+ const confirm = mfaEnableScreen.confirm;
37
+ expect(confirm?.handler).toBe(AuthMfaHandlers.enableConfirm);
38
+ expect(confirm?.carry).toEqual(["setupToken"]);
39
+ expect(Object.keys(confirm?.fields ?? {})).toEqual(["code"]);
40
+ });
41
+
42
+ test("every i18n key the screen references is declared in AUTH_MFA_FEATURE_I18N", () => {
43
+ const referenced = [
44
+ mfaEnableScreen.submitLabel,
45
+ mfaEnableScreen.reveal.title,
46
+ mfaEnableScreen.reveal.warning,
47
+ ...mfaEnableScreen.reveal.fields.map((f) => f.label),
48
+ mfaEnableScreen.confirm?.submitLabel,
49
+ mfaEnableScreen.confirm?.doneMessage,
50
+ fieldLabelKey("auth-mfa", "__action-form__", "code"),
51
+ ].filter((key): key is string => typeof key === "string");
52
+
53
+ expect(referenced.length).toBeGreaterThan(0);
54
+ for (const key of referenced) {
55
+ expect(AUTH_MFA_FEATURE_I18N[key]).toBeDefined();
56
+ }
57
+ });
58
+ });
@@ -1,6 +1,5 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { mfaRequiredConfigKey } from "./config";
3
- import { MFA_ENABLE_SCREEN_ID } from "./constants";
4
3
  import { createDisableHandler } from "./handlers/disable.write";
5
4
  import { createEnableConfirmHandler } from "./handlers/enable-confirm.write";
6
5
  import { createEnableConfirmPreauthHandler } from "./handlers/enable-confirm-preauth.write";
@@ -14,6 +13,7 @@ import { AUTH_MFA_FEATURE_I18N } from "./i18n";
14
13
  import { createMfaCodeVerifier, type MfaCodeVerifier } from "./mfa-code-verifier";
15
14
  import { createMfaStatusChecker, type MfaStatusChecker } from "./mfa-status-checker";
16
15
  import { userMfaEntity } from "./schema/user-mfa";
16
+ import { mfaEnableScreen } from "./screens";
17
17
 
18
18
  export type AuthMfaFeatureOptions = {
19
19
  // HMAC secret for the stateless enable-flow token (carries the generated
@@ -125,21 +125,11 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
125
125
  r.requires("tenant");
126
126
  r.config("required", mfaRequiredConfigKey());
127
127
 
128
- // Dormant custom-screen — the client maps MFA_ENABLE_SCREEN_ID to
129
- // MfaEnableScreen (see personal-access-tokens/feature.ts for the same
130
- // convention). App places it via r.nav in its logged-in settings area.
131
- // dormant: true skips createKumikoApp's missing-client-plugin boot
132
- // diagnostic (#2025) for apps that don't nav this screen (#2034).
133
- // kumiko-lint-ignore app-feature-structure Multi-step auth flow (enable + recovery-codes reveal), no declarative screen type covers this
134
- r.screen({
135
- id: MFA_ENABLE_SCREEN_ID,
136
- type: "custom",
137
- renderer: { react: { __component: "MfaEnableScreen" } },
138
- access: { openToAll: true },
139
- dormant: true,
140
- description:
141
- "Self-service screen where a signed-in user enrolls in TOTP two-factor authentication: it shows the QR code and manual secret, reveals the recovery codes once, and confirms enrollment with a code from their authenticator app.",
142
- });
128
+ // Declarative secretMint screen — mint (no input) -> reveal QR/secret/
129
+ // recovery-codes -> confirm with a code. App places it via r.nav in its
130
+ // logged-in settings area (see personal-access-tokens/feature.ts for the
131
+ // same convention).
132
+ r.screen(mfaEnableScreen);
143
133
  r.translations({ keys: AUTH_MFA_FEATURE_I18N });
144
134
 
145
135
  // KEK-rotation for totpSecret (entity-field encryption). Manual
@@ -1,5 +1,6 @@
1
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { z } from "zod";
3
+ import { UserQueries } from "../../user";
3
4
  import { base32Encode } from "../base32";
4
5
  import { MFA_SETUP_TOKEN_TTL_MINUTES } from "../constants";
5
6
  import { findUserMfaRow } from "../db/queries";
@@ -23,22 +24,31 @@ export function createEnableStartHandler(opts: EnableStartOptions) {
23
24
  return defineWriteHandler({
24
25
  name: "enable-start",
25
26
  schema: z.object({
26
- // Client-supplied label for the otpauth:// URI / authenticator-app
27
- // entry (typically the user's own email) avoids an extra DB lookup
28
- // for something the already-authenticated client already knows about
29
- // itself.
30
- accountLabel: z.string().min(1).max(200),
27
+ // Optional client-supplied label for the otpauth:// URI / authenticator-
28
+ // app entry. Omitted by the declarative secretMint screen (mint has no
29
+ // input fields) derived server-side from the caller's own email below.
30
+ accountLabel: z.string().min(1).max(200).optional(),
31
31
  }),
32
32
  access: { openToAll: true },
33
33
  description:
34
- "Begins TOTP enrollment for the signed-in user by generating a secret plus recovery codes and returning them as a short-lived setup token, an otpauth:// URI and the one-time plaintext recovery codes; nothing is persisted until enable-confirm.",
35
- // The result is the TOTP secret, the otpauth:// URI and the plaintext
34
+ "Begins TOTP enrollment for the signed-in user by generating a secret plus recovery codes and returning them as a short-lived setup token, an otpauth:// URI, the base32 secret for manual entry and the one-time plaintext recovery codes; nothing is persisted until enable-confirm. accountLabel defaults to the caller's own email.",
35
+ // The result carries the TOTP secret, the otpauth:// URI and the plaintext
36
36
  // recovery codes — an agent turn would put all three in the LLM transcript.
37
37
  agent: { expose: false },
38
38
  handler: async (event, ctx) => {
39
39
  const existing = await findUserMfaRow(ctx.db, event.user);
40
40
  if (existing) return mfaAlreadyEnabled();
41
41
 
42
+ let accountLabel = event.payload.accountLabel;
43
+ if (!accountLabel) {
44
+ // The declarative secretMint screen has no client component left to
45
+ // read the session email off — derive the otpauth label server-side.
46
+ const me = (await ctx.queryAs(event.user, UserQueries.me, {})) as {
47
+ email?: string;
48
+ } | null; // @cast-boundary engine-payload
49
+ accountLabel = me?.email ?? event.user.id;
50
+ }
51
+
42
52
  const secret = generateTotpSecret();
43
53
  const recoveryCodes = generateRecoveryCodes();
44
54
  const recoveryCodeHashes = await hashRecoveryCodes(recoveryCodes);
@@ -57,11 +67,10 @@ export function createEnableStartHandler(opts: EnableStartOptions) {
57
67
  isSuccess: true,
58
68
  data: {
59
69
  setupToken,
60
- otpauthUri: buildOtpauthUri({
61
- issuer: opts.issuer,
62
- accountLabel: event.payload.accountLabel,
63
- secret,
64
- }),
70
+ otpauthUri: buildOtpauthUri({ issuer: opts.issuer, accountLabel, secret }),
71
+ // The otpauth URI already carries this secret in its query string —
72
+ // surfaced separately for the reveal screen's manual-entry display.
73
+ totpSecret: base32Encode(secret),
65
74
  // Plaintext — this is the one and only time these are shown.
66
75
  recoveryCodes,
67
76
  },
@@ -8,4 +8,23 @@ export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
8
8
  "screen:auth-mfa-enable.title": {
9
9
  en: "Two-factor authentication",
10
10
  },
11
+
12
+ "mfa.enable.start": { en: "Start setup" },
13
+
14
+ "mfa.enable.reveal.title": { en: "Set up your authenticator app" },
15
+ "mfa.enable.reveal.warning": {
16
+ en: "Save your recovery codes now — for security everything on this screen is shown only this once.",
17
+ },
18
+ "mfa.enable.reveal.qr": { en: "Scan this QR code" },
19
+ "mfa.enable.reveal.secret": { en: "Or enter this code manually" },
20
+ "mfa.enable.reveal.recoveryCodes": { en: "Recovery codes" },
21
+
22
+ "mfa.enable.confirm.submit": { en: "Enable" },
23
+ "mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
24
+
25
+ // Field label for the confirm step's pseudo-entity (`__action-form__`, see
26
+ // action-form-shim.ts) — required by the i18n boot-validator
27
+ // (requiredKeysFromScreen); the declarative renderer resolves this through
28
+ // the schema payload, not a client component's t().
29
+ "auth-mfa:entity:__action-form__:field:code": { en: "Code from your authenticator app" },
11
30
  };
@@ -0,0 +1,52 @@
1
+ import { i18nKey, type SecretMintScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { AuthMfaHandlers, MFA_ENABLE_SCREEN_ID } from "./constants";
3
+
4
+ // Declarative TOTP-enrollment screen: mint (no input) -> reveal QR/secret/
5
+ // recovery-codes -> confirm with a 6-digit code. `setupToken` is carried
6
+ // from the mint payload into the confirm payload but never listed in
7
+ // `reveal.fields` — it is threaded through, not shown.
8
+ export const mfaEnableScreen: SecretMintScreenDefinition = {
9
+ id: MFA_ENABLE_SCREEN_ID,
10
+ type: "secretMint",
11
+ handler: AuthMfaHandlers.enableStart,
12
+ fields: {},
13
+ layout: { sections: [] },
14
+ submitLabel: i18nKey("mfa.enable.start"),
15
+ access: { openToAll: true },
16
+ description:
17
+ "Self-service screen where a signed-in user enrolls in TOTP two-factor authentication: it shows the QR code and manual secret, reveals the recovery codes once, and confirms enrollment with a code from their authenticator app.",
18
+ reveal: {
19
+ title: i18nKey("mfa.enable.reveal.title"),
20
+ warning: i18nKey("mfa.enable.reveal.warning"),
21
+ fields: [
22
+ {
23
+ field: "otpauthUri",
24
+ label: i18nKey("mfa.enable.reveal.qr"),
25
+ display: "qr",
26
+ copyable: false,
27
+ },
28
+ {
29
+ field: "totpSecret",
30
+ label: i18nKey("mfa.enable.reveal.secret"),
31
+ display: "code",
32
+ copyable: true,
33
+ },
34
+ {
35
+ field: "recoveryCodes",
36
+ label: i18nKey("mfa.enable.reveal.recoveryCodes"),
37
+ display: "list",
38
+ copyable: true,
39
+ },
40
+ ],
41
+ },
42
+ confirm: {
43
+ handler: AuthMfaHandlers.enableConfirm,
44
+ fields: {
45
+ code: { type: "text", required: true, maxLength: 6 },
46
+ },
47
+ layout: { sections: [{ fields: ["code"] }] },
48
+ carry: ["setupToken"],
49
+ submitLabel: i18nKey("mfa.enable.confirm.submit"),
50
+ doneMessage: i18nKey("mfa.enable.confirm.done"),
51
+ },
52
+ };
@@ -4,14 +4,13 @@
4
4
  // createKumikoApp({ clientFeatures: [emailPasswordClient(), authMfaClient()] }).
5
5
  // No gates: MfaVerifyScreen isn't a route gate, it's a state swap wired
6
6
  // via EmailPasswordClientOptions.mfaVerifyScreen. This factory merges the
7
- // default de/en translations and maps MFA_ENABLE_SCREEN_ID to
8
- // MfaEnableScreen (same "components" convention as personal-access-tokens).
7
+ // default de/en translations for the Verify/Disable/Regenerate components —
8
+ // the enroll screen is a declarative secretMint (feature.ts's r.screen),
9
+ // no client component of its own.
9
10
 
10
11
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
11
12
  import type { ComponentType, ReactNode } from "react";
12
- import { MFA_ENABLE_SCREEN_ID } from "../constants";
13
13
  import { defaultTranslations, mergeTranslations } from "./i18n";
14
- import { MfaEnableScreen } from "./mfa-enable-screen";
15
14
 
16
15
  export type AuthMfaClientOptions = {
17
16
  /** Key-Overrides pro Locale, gemerged mit den Default-Bundles (de/en). */
@@ -32,6 +31,6 @@ export function authMfaClient(options: AuthMfaClientOptions = {}): AuthMfaClient
32
31
  providers: [],
33
32
  gates: [],
34
33
  translations: mergeTranslations(defaultTranslations, options.translations ?? {}),
35
- components: { [MFA_ENABLE_SCREEN_ID]: MfaEnableScreen },
34
+ components: {},
36
35
  };
37
36
  }
@@ -27,10 +27,6 @@ export const defaultTranslations: TranslationsByLocale = {
27
27
  "auth.mfa.errors.invalidSetupToken": "Setup expired. Please start again.",
28
28
  "auth.mfa.errors.setupFailed": "Setup failed. Please try again.",
29
29
  "auth.mfa.errors.invalidRecoveryCode": "Invalid recovery code.",
30
- "auth.mfa.enable.title": "Two-factor authentication",
31
- "auth.mfa.enable.intro":
32
- "Add an extra layer of protection with an authenticator app (e.g. Google Authenticator, 1Password).",
33
- "auth.mfa.enable.start": "Start setup",
34
30
  "auth.mfa.enable.scanTitle": "Scan the QR code",
35
31
  "auth.mfa.enable.manualEntry": "Or enter manually:",
36
32
  "auth.mfa.enable.recoveryTitle": "Recovery codes",
@@ -38,9 +34,6 @@ export const defaultTranslations: TranslationsByLocale = {
38
34
  "Save these codes somewhere safe. They're shown only this once and let you back in if you lose your device.",
39
35
  "auth.mfa.enable.acknowledge": "I've saved my recovery codes.",
40
36
  "auth.mfa.enable.code": "Code from your authenticator app",
41
- "auth.mfa.enable.cancel": "Cancel",
42
- "auth.mfa.enable.confirm": "Enable",
43
- "auth.mfa.enable.success": "Two-factor authentication is now enabled.",
44
37
  "auth.mfa.setup.title": "Two-factor authentication required",
45
38
  "auth.mfa.setup.subtitle":
46
39
  "Your account requires two-factor authentication. Set it up now to sign in.",
@@ -15,8 +15,6 @@ export type {
15
15
  export { confirmMfaSetupPreauth, startMfaSetupPreauth, verifyMfaChallenge } from "./mfa-client";
16
16
  export type { MfaDisableDialogProps } from "./mfa-disable-dialog";
17
17
  export { MfaDisableDialog } from "./mfa-disable-dialog";
18
- export type { MfaEnableScreenProps } from "./mfa-enable-screen";
19
- export { MfaEnableScreen } from "./mfa-enable-screen";
20
18
  export type { MfaRecoveryCodesRevealProps } from "./mfa-recovery-codes-reveal";
21
19
  export { MfaRecoveryCodesReveal } from "./mfa-recovery-codes-reveal";
22
20
  export type { MfaRegenerateRecoveryDialogProps } from "./mfa-regenerate-recovery-dialog";
@@ -1,7 +1,7 @@
1
1
  // @runtime client
2
2
  // MfaDisableDialog — confirm-with-code before turning MFA off. Uses the
3
- // generic Dialog primitive (unlike MfaEnableScreen/MfaRegenerateRecovery-
4
- // Dialog): disable is single-step, no state to preserve after confirm, so
3
+ // generic Dialog primitive (unlike MfaRegenerateRecoveryDialog): disable is
4
+ // single-step, no state to preserve after confirm, so
5
5
  // it doesn't hit the "Dialog always closes after onConfirm" problem that
6
6
  // ruled Dialog out for the multi-step enable/regenerate flows. Error
7
7
  // reporting still has to go through the parent (onError) rather than an
@@ -1,9 +1,9 @@
1
1
  // @runtime client
2
2
  // MfaRecoveryCodesReveal — one-time display of a fresh recovery-code set,
3
- // with an acknowledge-then-dismiss gate (same UX as the codes block in
4
- // MfaEnableScreen). Standalone so MfaRegenerateRecoveryDialog's caller can
5
- // render it after the dialog closes the codes only exist in memory for
6
- // this one render, never persisted anywhere the app could show them again.
3
+ // with an acknowledge-then-dismiss gate. Standalone so MfaRegenerateRecovery-
4
+ // Dialog's caller can render it after the dialog closes — the codes only
5
+ // exist in memory for this one render, never persisted anywhere the app
6
+ // could show them again.
7
7
 
8
8
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
9
9
  import { type ReactNode, useState } from "react";
@@ -1,9 +1,9 @@
1
1
  // @runtime client
2
- // Pre-auth twin of MfaEnableScreen: a user blocked at login by MFA
3
- // enforcement and not yet enrolled lands here straight out of the login
4
- // flow — there is no session yet. Identity comes entirely from the
5
- // preauthSetupToken login()'s mfa-setup-required result carries (see
6
- // auth-client.ts's LoginResult). Calls startMfaSetupPreauth/confirmMfa-
2
+ // Pre-auth twin of the declarative auth-mfa-enable secretMint screen: a user
3
+ // blocked at login by MFA enforcement and not yet enrolled lands here
4
+ // straight out of the login flow — there is no session yet. Identity comes
5
+ // entirely from the preauthSetupToken login()'s mfa-setup-required result
6
+ // carries (see auth-client.ts's LoginResult). Calls startMfaSetupPreauth/confirmMfa-
7
7
  // SetupPreauth directly via fetch (mfa-client.ts) — no useSession(), no
8
8
  // useDispatcher(), both assume a real session that doesn't exist here.
9
9
  //
@@ -1,6 +1,6 @@
1
1
  // @types/qrcode only declares the main "qrcode" entry, not the browser-only
2
- // subpath (see mfa-enable-screen.tsx's import — the main entry pulls in
3
- // Node-only deps like yargs/pngjs via qrcode's own package.json#browser
2
+ // subpath (see mfa-setup-preauth-screen.tsx's import — the main entry pulls
3
+ // in Node-only deps like yargs/pngjs via qrcode's own package.json#browser
4
4
  // remap, which Metro doesn't honor). Same runtime shape, just re-typed.
5
5
  // `export *` never re-exports a default — `import QRCode from "..."`
6
6
  // resolving here relies on the consuming app's esModuleInterop/