@cosmicdrift/kumiko-bundled-features 0.147.3 → 0.149.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.147.3",
3
+ "version": "0.149.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>",
@@ -114,11 +114,11 @@
114
114
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
115
115
  },
116
116
  "dependencies": {
117
- "@cosmicdrift/kumiko-dispatcher-live": "0.147.3",
118
- "@cosmicdrift/kumiko-framework": "0.147.3",
119
- "@cosmicdrift/kumiko-headless": "0.147.3",
120
- "@cosmicdrift/kumiko-renderer": "0.147.3",
121
- "@cosmicdrift/kumiko-renderer-web": "0.147.3",
117
+ "@cosmicdrift/kumiko-dispatcher-live": "0.149.0",
118
+ "@cosmicdrift/kumiko-framework": "0.149.0",
119
+ "@cosmicdrift/kumiko-headless": "0.149.0",
120
+ "@cosmicdrift/kumiko-renderer": "0.149.0",
121
+ "@cosmicdrift/kumiko-renderer-web": "0.149.0",
122
122
  "@mollie/api-client": "^4.5.0",
123
123
  "imapflow": "^1.3.3",
124
124
  "mailparser": "^3.9.8",
@@ -15,7 +15,7 @@ describe("audit log screen + handler access alignment", () => {
15
15
  test("audit-log screen is custom, access.admin-gated", () => {
16
16
  const audit = createAuditFeature();
17
17
  const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
18
- if (!screen || screen.type !== "custom") {
18
+ if (screen?.type !== "custom") {
19
19
  throw new Error(`expected a custom screen for ${AUDIT_LOG_SCREEN_ID}, got ${screen?.type}`);
20
20
  }
21
21
  if (!("access" in screen) || !screen.access || !("roles" in screen.access)) {
@@ -27,7 +27,7 @@ describe("audit log screen + handler access alignment", () => {
27
27
  test("audit-log-detail screen is custom, admin-gated, breadcrumb-linked to list", () => {
28
28
  const audit = createAuditFeature();
29
29
  const screen = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
30
- if (!screen || screen.type !== "custom") {
30
+ if (screen?.type !== "custom") {
31
31
  throw new Error(
32
32
  `expected a custom screen for ${AUDIT_LOG_DETAIL_SCREEN_ID}, got ${screen?.type}`,
33
33
  );
@@ -17,7 +17,7 @@ import { configValuesTable } from "../../config/table";
17
17
  import { createUserFeature } from "../../user/feature";
18
18
  import { userEntity } from "../../user/schema/user";
19
19
  import { base32Decode } from "../base32";
20
- import { AuthMfaHandlers } from "../constants";
20
+ import { AuthMfaHandlers, AuthMfaQueries } from "../constants";
21
21
  import { createAuthMfaFeature } from "../feature";
22
22
  import { userMfaEntity } from "../schema/user-mfa";
23
23
  import { currentTotpCode } from "../totp";
@@ -147,4 +147,27 @@ describe("enable-start + enable-confirm round trip", () => {
147
147
  );
148
148
  expectErrorIncludes(err, "invalid_setup_token");
149
149
  });
150
+
151
+ test("status query reflects enrollment before and after enable-confirm", async () => {
152
+ const user = createTestUser({ id: "status-query-1", roles: ["User"] });
153
+
154
+ const before = await stack.http.queryOk<{ enabled: boolean }>(AuthMfaQueries.status, {}, user);
155
+ expect(before.enabled).toBe(false);
156
+
157
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
158
+ AuthMfaHandlers.enableStart,
159
+ { accountLabel: "status@example.com" },
160
+ user,
161
+ );
162
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
163
+ const code = currentTotpCode(base32Decode(secretParam));
164
+ await stack.http.writeOk(
165
+ AuthMfaHandlers.enableConfirm,
166
+ { setupToken: start.setupToken, code },
167
+ user,
168
+ );
169
+
170
+ const after = await stack.http.queryOk<{ enabled: boolean }>(AuthMfaQueries.status, {}, user);
171
+ expect(after.enabled).toBe(true);
172
+ });
150
173
  });
@@ -15,6 +15,10 @@ export const AuthMfaHandlers = {
15
15
  verify: "auth-mfa:write:verify",
16
16
  } as const;
17
17
 
18
+ export const AuthMfaQueries = {
19
+ status: "auth-mfa:query:user-mfa:status",
20
+ } as const;
21
+
18
22
  export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
19
23
  export const MFA_CHALLENGE_TOKEN_TTL_MINUTES = 10;
20
24
  export const MFA_VERIFY_MAX_ATTEMPTS = 5;
@@ -6,6 +6,7 @@ import { createEnableConfirmHandler } from "./handlers/enable-confirm.write";
6
6
  import { createEnableStartHandler } from "./handlers/enable-start.write";
7
7
  import { mfaReencryptJob } from "./handlers/reencrypt.job";
8
8
  import { createRegenerateRecoveryHandler } from "./handlers/regenerate-recovery.write";
9
+ import { mfaStatusQuery } from "./handlers/status.query";
9
10
  import { createMfaVerifyHandler } from "./handlers/verify.write";
10
11
  import { AUTH_MFA_FEATURE_I18N } from "./i18n";
11
12
  import { createMfaStatusChecker, type MfaStatusChecker } from "./mfa-status-checker";
@@ -129,6 +130,10 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
129
130
  ),
130
131
  };
131
132
 
133
+ const queries = {
134
+ status: r.queryHandler(mfaStatusQuery),
135
+ };
136
+
132
137
  // No late-bind needed (unlike sharedRevoker) — this checker only needs
133
138
  // the HandlerContext the CALLER already has (login.write.ts runs it
134
139
  // from inside its own dispatcher call), not a raw db handle assembled
@@ -141,6 +146,6 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
141
146
  revokeAllOtherSessions = revoker;
142
147
  };
143
148
 
144
- return { handlers, bindRevokeAllOtherSessions, checkMfaStatus };
149
+ return { handlers, queries, bindRevokeAllOtherSessions, checkMfaStatus };
145
150
  });
146
151
  }
@@ -0,0 +1,23 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { userMfaTable } from "../schema/user-mfa";
5
+
6
+ // "Is MFA enabled for me" — the one thing a settings screen needs before it
7
+ // can decide whether to show the enrollment flow or the disable/regenerate
8
+ // actions. No client-side signal (session/JWT) carries this today, so
9
+ // without this query every app builds its own row-existence check via a
10
+ // side channel. Plain fetchOne — no need for the entity's decrypting
11
+ // executor.detail() (see db/queries.ts's findUserMfaRow), existence is all
12
+ // the caller wants.
13
+ export const mfaStatusQuery = defineQueryHandler({
14
+ name: "user-mfa:status",
15
+ schema: z.object({}),
16
+ access: { openToAll: true },
17
+ handler: async (query, ctx) => {
18
+ const row = await fetchOne<{ id: string }>(ctx.db, userMfaTable, {
19
+ userId: query.user.id,
20
+ });
21
+ return { enabled: row !== undefined };
22
+ },
23
+ });
@@ -1,6 +1,11 @@
1
1
  export { base32Decode } from "./base32";
2
2
  export { type MfaRequiredPolicy, mfaRequiredConfigHandle } from "./config";
3
- export { AUTH_MFA_FEATURE, AuthMfaHandlers, MFA_ENABLE_SCREEN_ID } from "./constants";
3
+ export {
4
+ AUTH_MFA_FEATURE,
5
+ AuthMfaHandlers,
6
+ AuthMfaQueries,
7
+ MFA_ENABLE_SCREEN_ID,
8
+ } from "./constants";
4
9
  export type {
5
10
  AuthMfaFeatureOptions,
6
11
  BindMfaRevokeAllOtherSessions,
@@ -7,6 +7,7 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
7
7
 
8
8
  export const defaultTranslations: TranslationsByLocale = {
9
9
  de: {
10
+ "screen:auth-mfa-enable.title": "Zwei-Faktor-Authentifizierung",
10
11
  "auth.mfa.verify.title": "Zwei-Faktor-Bestätigung",
11
12
  "auth.mfa.verify.subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
12
13
  "auth.mfa.verify.code": "Code",
@@ -34,8 +35,28 @@ export const defaultTranslations: TranslationsByLocale = {
34
35
  "auth.mfa.enable.cancel": "Abbrechen",
35
36
  "auth.mfa.enable.confirm": "Aktivieren",
36
37
  "auth.mfa.enable.success": "Zwei-Faktor-Authentifizierung ist jetzt aktiv.",
38
+ "auth.mfa.disable.title": "Zwei-Faktor-Authentifizierung deaktivieren",
39
+ "auth.mfa.disable.description":
40
+ "Bestätige mit einem Code aus deiner Authenticator-App oder einem Recovery-Code. Dein Konto ist danach nur noch durch dein Passwort geschützt.",
41
+ "auth.mfa.disable.code": "Code aus der Authenticator-App oder Recovery-Code",
42
+ "auth.mfa.disable.confirm": "Deaktivieren",
43
+ "auth.mfa.disable.cancel": "Abbrechen",
44
+ "auth.mfa.disable.trigger": "Zwei-Faktor-Authentifizierung deaktivieren",
45
+ "auth.mfa.regenerate.title": "Neue Recovery-Codes erzeugen",
46
+ "auth.mfa.regenerate.description":
47
+ "Bestätige mit einem Code aus deiner Authenticator-App. Alle bisherigen Recovery-Codes werden sofort ungültig.",
48
+ "auth.mfa.regenerate.code": "Code aus der Authenticator-App",
49
+ "auth.mfa.regenerate.confirm": "Neu erzeugen",
50
+ "auth.mfa.regenerate.cancel": "Abbrechen",
51
+ "auth.mfa.regenerate.trigger": "Neue Recovery-Codes erzeugen",
52
+ "auth.mfa.regenerate.newCodesTitle": "Deine neuen Recovery-Codes",
53
+ "auth.mfa.regenerate.newCodesHint":
54
+ "Speichere diese Codes an einem sicheren Ort. Die alten Codes funktionieren ab sofort nicht mehr.",
55
+ "auth.mfa.regenerate.acknowledge": "Ich habe die neuen Recovery-Codes gespeichert.",
56
+ "auth.mfa.regenerate.done": "Fertig",
37
57
  },
38
58
  en: {
59
+ "screen:auth-mfa-enable.title": "Two-factor authentication",
39
60
  "auth.mfa.verify.title": "Two-factor verification",
40
61
  "auth.mfa.verify.subtitle": "Enter the 6-digit code from your authenticator app.",
41
62
  "auth.mfa.verify.code": "Code",
@@ -63,6 +84,25 @@ export const defaultTranslations: TranslationsByLocale = {
63
84
  "auth.mfa.enable.cancel": "Cancel",
64
85
  "auth.mfa.enable.confirm": "Enable",
65
86
  "auth.mfa.enable.success": "Two-factor authentication is now enabled.",
87
+ "auth.mfa.disable.title": "Disable two-factor authentication",
88
+ "auth.mfa.disable.description":
89
+ "Confirm with a code from your authenticator app or a recovery code. Your account will then be protected by your password alone.",
90
+ "auth.mfa.disable.code": "Code from your authenticator app or a recovery code",
91
+ "auth.mfa.disable.confirm": "Disable",
92
+ "auth.mfa.disable.cancel": "Cancel",
93
+ "auth.mfa.disable.trigger": "Disable two-factor authentication",
94
+ "auth.mfa.regenerate.title": "Generate new recovery codes",
95
+ "auth.mfa.regenerate.description":
96
+ "Confirm with a code from your authenticator app. All existing recovery codes stop working immediately.",
97
+ "auth.mfa.regenerate.code": "Code from your authenticator app",
98
+ "auth.mfa.regenerate.confirm": "Generate new codes",
99
+ "auth.mfa.regenerate.cancel": "Cancel",
100
+ "auth.mfa.regenerate.trigger": "Generate new recovery codes",
101
+ "auth.mfa.regenerate.newCodesTitle": "Your new recovery codes",
102
+ "auth.mfa.regenerate.newCodesHint":
103
+ "Save these codes somewhere safe. The old codes stop working immediately.",
104
+ "auth.mfa.regenerate.acknowledge": "I've saved my new recovery codes.",
105
+ "auth.mfa.regenerate.done": "Done",
66
106
  },
67
107
  };
68
108
 
@@ -9,7 +9,14 @@ export { authMfaClient } from "./client-plugin";
9
9
  export { defaultTranslations, mergeTranslations } from "./i18n";
10
10
  export type { MfaVerifyResult } from "./mfa-client";
11
11
  export { verifyMfaChallenge } from "./mfa-client";
12
+ export type { MfaDisableDialogProps } from "./mfa-disable-dialog";
13
+ export { MfaDisableDialog } from "./mfa-disable-dialog";
12
14
  export type { MfaEnableScreenProps } from "./mfa-enable-screen";
13
15
  export { MfaEnableScreen } from "./mfa-enable-screen";
16
+ export { mfaManageErrorKey } from "./mfa-error-keys";
17
+ export type { MfaRecoveryCodesRevealProps } from "./mfa-recovery-codes-reveal";
18
+ export { MfaRecoveryCodesReveal } from "./mfa-recovery-codes-reveal";
19
+ export type { MfaRegenerateRecoveryDialogProps } from "./mfa-regenerate-recovery-dialog";
20
+ export { MfaRegenerateRecoveryDialog } from "./mfa-regenerate-recovery-dialog";
14
21
  export type { MfaVerifyScreenProps } from "./mfa-verify-screen";
15
22
  export { MfaVerifyScreen } from "./mfa-verify-screen";
@@ -0,0 +1,70 @@
1
+ // @runtime client
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
5
+ // it doesn't hit the "Dialog always closes after onConfirm" problem that
6
+ // ruled Dialog out for the multi-step enable/regenerate flows. Error
7
+ // reporting still has to go through the parent (onError) rather than an
8
+ // inline Banner, because Dialog closes in a `finally` regardless of
9
+ // whether onConfirm's write succeeded — see profile-screen.tsx's
10
+ // delete-account dialog for the same convention (StatusBanner rendered
11
+ // by the caller, not the dialog).
12
+
13
+ import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
14
+ import { type ReactNode, useState } from "react";
15
+ import { AuthMfaHandlers } from "../constants";
16
+ import { mfaManageErrorKey } from "./mfa-error-keys";
17
+
18
+ export type MfaDisableDialogProps = {
19
+ readonly open: boolean;
20
+ readonly onOpenChange: (open: boolean) => void;
21
+ readonly onDisabled: () => void;
22
+ readonly onError: (i18nKey: string) => void;
23
+ };
24
+
25
+ export function MfaDisableDialog({
26
+ open,
27
+ onOpenChange,
28
+ onDisabled,
29
+ onError,
30
+ }: MfaDisableDialogProps): ReactNode {
31
+ const t = useTranslation();
32
+ const { Dialog, Field, Input } = usePrimitives();
33
+ const dispatcher = useDispatcher();
34
+ const [code, setCode] = useState("");
35
+
36
+ const confirm = async (): Promise<void> => {
37
+ const res = await dispatcher.write<{ disabled: boolean }>(AuthMfaHandlers.disable, { code });
38
+ setCode("");
39
+ if (!res.isSuccess) {
40
+ onError(mfaManageErrorKey(res.error.code));
41
+ return;
42
+ }
43
+ onDisabled();
44
+ };
45
+
46
+ return (
47
+ <Dialog
48
+ open={open}
49
+ onOpenChange={onOpenChange}
50
+ variant="danger"
51
+ title={t("auth.mfa.disable.title")}
52
+ description={t("auth.mfa.disable.description")}
53
+ confirmLabel={t("auth.mfa.disable.confirm")}
54
+ cancelLabel={t("auth.mfa.disable.cancel")}
55
+ onConfirm={confirm}
56
+ testId="mfa-disable-dialog"
57
+ >
58
+ <Field id="mfa-disable-code" label={t("auth.mfa.disable.code")} required>
59
+ <Input
60
+ kind="text"
61
+ id="mfa-disable-code"
62
+ name="mfa-disable-code"
63
+ value={code}
64
+ onChange={setCode}
65
+ autoComplete="one-time-code"
66
+ />
67
+ </Field>
68
+ </Dialog>
69
+ );
70
+ }
@@ -14,6 +14,7 @@ import { type ReactNode, useState } from "react";
14
14
  // kumiko-lint-ignore cross-feature-import client-only hook, the feature's server barrel has no web/ re-export
15
15
  import { useSession } from "../../auth-email-password/web";
16
16
  import { AuthMfaHandlers } from "../constants";
17
+ import { mfaManageErrorKey } from "./mfa-error-keys";
17
18
 
18
19
  type EnableStartResponse = {
19
20
  readonly setupToken: string;
@@ -39,7 +40,7 @@ export type MfaEnableScreenProps = { readonly embedded?: boolean };
39
40
 
40
41
  export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {}): ReactNode {
41
42
  const t = useTranslation();
42
- const { Button, Banner, Field, Input, Card, Heading } = usePrimitives();
43
+ const { Button, Banner, Field, Input, Section, Heading } = usePrimitives();
43
44
  const dispatcher = useDispatcher();
44
45
  const session = useSession();
45
46
 
@@ -95,25 +96,46 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
95
96
  <Heading>{t("auth.mfa.enable.title")}</Heading>
96
97
 
97
98
  {enabled && <Banner variant="info">{t("auth.mfa.enable.success")}</Banner>}
98
- {error !== null && <Banner variant="error">{t(`auth.mfa.errors.${error}`)}</Banner>}
99
+ {error !== null && <Banner variant="error">{t(mfaManageErrorKey(error))}</Banner>}
99
100
 
100
101
  {!setup && !enabled && (
101
- <Card options={{ padded: true }} className="flex flex-col gap-3">
102
+ <Section
103
+ testId="mfa-enable-intro"
104
+ actions={
105
+ <Button
106
+ variant="primary"
107
+ onClick={() => void startSetup()}
108
+ loading={busy}
109
+ disabled={busy}
110
+ >
111
+ {t("auth.mfa.enable.start")}
112
+ </Button>
113
+ }
114
+ >
102
115
  <span className="text-sm text-muted-foreground">{t("auth.mfa.enable.intro")}</span>
103
- <Button
104
- variant="primary"
105
- onClick={() => void startSetup()}
106
- loading={busy}
107
- disabled={busy}
108
- >
109
- {t("auth.mfa.enable.start")}
110
- </Button>
111
- </Card>
116
+ </Section>
112
117
  )}
113
118
 
114
119
  {setup && (
115
- <Card options={{ padded: true }} className="flex flex-col gap-4">
116
- <div className="flex flex-col gap-2">
120
+ <Section
121
+ testId="mfa-enable-setup"
122
+ actions={
123
+ <>
124
+ <Button variant="secondary" onClick={() => setSetup(null)} disabled={busy}>
125
+ {t("auth.mfa.enable.cancel")}
126
+ </Button>
127
+ <Button
128
+ variant="primary"
129
+ onClick={() => void confirmSetup()}
130
+ loading={busy}
131
+ disabled={busy || !acknowledged || code.length < 6}
132
+ >
133
+ {t("auth.mfa.enable.confirm")}
134
+ </Button>
135
+ </>
136
+ }
137
+ >
138
+ <div className="flex flex-col items-center gap-2 text-center">
117
139
  <span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
118
140
  {/* qrcode's own SVG string output, not user input — safe to inline */}
119
141
  <div
@@ -124,7 +146,7 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
124
146
  <span className="text-xs text-muted-foreground">
125
147
  {t("auth.mfa.enable.manualEntry")}
126
148
  </span>
127
- <code className="block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
149
+ <code className="inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
128
150
  {setup.secretParam}
129
151
  </code>
130
152
  </div>
@@ -159,21 +181,7 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
159
181
  autoComplete="one-time-code"
160
182
  />
161
183
  </Field>
162
-
163
- <div className="flex justify-end gap-2">
164
- <Button variant="secondary" onClick={() => setSetup(null)} disabled={busy}>
165
- {t("auth.mfa.enable.cancel")}
166
- </Button>
167
- <Button
168
- variant="primary"
169
- onClick={() => void confirmSetup()}
170
- loading={busy}
171
- disabled={busy || !acknowledged || code.length < 6}
172
- >
173
- {t("auth.mfa.enable.confirm")}
174
- </Button>
175
- </div>
176
- </Card>
184
+ </Section>
177
185
  )}
178
186
  </div>
179
187
  );
@@ -0,0 +1,25 @@
1
+ // @runtime client
2
+ // Shared error-code → i18n-key mapping for the account-management write
3
+ // handlers (enable-confirm/disable/regenerate-recovery) — distinct from
4
+ // mfa-verify-screen.tsx's own mapper, which covers the login-challenge
5
+ // error surface (challenge_expired/rate_limited) that these handlers never
6
+ // return. Centralized so enable/disable/regenerate stay in sync instead of
7
+ // each re-deriving the key ad hoc (enable-screen used to template-string
8
+ // the raw snake_case code straight into the key, which never matched the
9
+ // camelCase keys actually registered below).
10
+ export function mfaManageErrorKey(code: string): string {
11
+ switch (code) {
12
+ case "invalid_totp_code":
13
+ return "auth.mfa.errors.invalidCode";
14
+ case "invalid_recovery_code":
15
+ return "auth.mfa.errors.invalidRecoveryCode";
16
+ case "mfa_already_enabled":
17
+ return "auth.mfa.errors.mfaAlreadyEnabled";
18
+ case "mfa_not_enabled":
19
+ return "auth.mfa.errors.mfaNotEnabled";
20
+ case "invalid_setup_token":
21
+ return "auth.mfa.errors.invalidSetupToken";
22
+ default:
23
+ return "auth.mfa.errors.verifyFailed";
24
+ }
25
+ }
@@ -0,0 +1,53 @@
1
+ // @runtime client
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.
7
+
8
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
9
+ import { type ReactNode, useState } from "react";
10
+
11
+ export type MfaRecoveryCodesRevealProps = {
12
+ readonly codes: readonly string[];
13
+ readonly onDismiss: () => void;
14
+ };
15
+
16
+ export function MfaRecoveryCodesReveal({
17
+ codes,
18
+ onDismiss,
19
+ }: MfaRecoveryCodesRevealProps): ReactNode {
20
+ const t = useTranslation();
21
+ const { Button, Field, Input, Section } = usePrimitives();
22
+ const [acknowledged, setAcknowledged] = useState(false);
23
+
24
+ return (
25
+ <Section
26
+ testId="mfa-regenerate-reveal"
27
+ actions={
28
+ <Button variant="primary" onClick={onDismiss} disabled={!acknowledged}>
29
+ {t("auth.mfa.regenerate.done")}
30
+ </Button>
31
+ }
32
+ >
33
+ <div className="flex flex-col items-center gap-2 text-center">
34
+ <span className="text-sm font-semibold">{t("auth.mfa.regenerate.newCodesTitle")}</span>
35
+ <span className="text-xs text-muted-foreground">
36
+ {t("auth.mfa.regenerate.newCodesHint")}
37
+ </span>
38
+ <code className="inline-block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
39
+ {codes.join("\n")}
40
+ </code>
41
+ </div>
42
+ <Field id="mfa-regenerate-ack" label={t("auth.mfa.regenerate.acknowledge")}>
43
+ <Input
44
+ kind="boolean"
45
+ id="mfa-regenerate-ack"
46
+ name="mfa-regenerate-ack"
47
+ value={acknowledged}
48
+ onChange={setAcknowledged}
49
+ />
50
+ </Field>
51
+ </Section>
52
+ );
53
+ }
@@ -0,0 +1,69 @@
1
+ // @runtime client
2
+ // MfaRegenerateRecoveryDialog — confirm-with-code, then the caller reveals
3
+ // the new codes. The reveal itself can't live inside this Dialog: Dialog
4
+ // closes in a `finally` right after onConfirm resolves (see profile-screen.
5
+ // tsx's delete-account dialog for the same constraint), so there's no way
6
+ // to keep it open to show the result. onRegenerated hands the new codes to
7
+ // the caller, which is expected to render them via MfaRecoveryCodesReveal
8
+ // (or equivalent) once the dialog has closed.
9
+
10
+ import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
11
+ import { type ReactNode, useState } from "react";
12
+ import { AuthMfaHandlers } from "../constants";
13
+ import { mfaManageErrorKey } from "./mfa-error-keys";
14
+
15
+ export type MfaRegenerateRecoveryDialogProps = {
16
+ readonly open: boolean;
17
+ readonly onOpenChange: (open: boolean) => void;
18
+ readonly onRegenerated: (codes: readonly string[]) => void;
19
+ readonly onError: (i18nKey: string) => void;
20
+ };
21
+
22
+ export function MfaRegenerateRecoveryDialog({
23
+ open,
24
+ onOpenChange,
25
+ onRegenerated,
26
+ onError,
27
+ }: MfaRegenerateRecoveryDialogProps): ReactNode {
28
+ const t = useTranslation();
29
+ const { Dialog, Field, Input } = usePrimitives();
30
+ const dispatcher = useDispatcher();
31
+ const [code, setCode] = useState("");
32
+
33
+ const confirm = async (): Promise<void> => {
34
+ const res = await dispatcher.write<{ recoveryCodes: readonly string[] }>(
35
+ AuthMfaHandlers.regenerateRecovery,
36
+ { code },
37
+ );
38
+ setCode("");
39
+ if (!res.isSuccess) {
40
+ onError(mfaManageErrorKey(res.error.code));
41
+ return;
42
+ }
43
+ onRegenerated(res.data.recoveryCodes);
44
+ };
45
+
46
+ return (
47
+ <Dialog
48
+ open={open}
49
+ onOpenChange={onOpenChange}
50
+ title={t("auth.mfa.regenerate.title")}
51
+ description={t("auth.mfa.regenerate.description")}
52
+ confirmLabel={t("auth.mfa.regenerate.confirm")}
53
+ cancelLabel={t("auth.mfa.regenerate.cancel")}
54
+ onConfirm={confirm}
55
+ testId="mfa-regenerate-recovery-dialog"
56
+ >
57
+ <Field id="mfa-regenerate-code" label={t("auth.mfa.regenerate.code")} required>
58
+ <Input
59
+ kind="text"
60
+ id="mfa-regenerate-code"
61
+ name="mfa-regenerate-code"
62
+ value={code}
63
+ onChange={setCode}
64
+ autoComplete="one-time-code"
65
+ />
66
+ </Field>
67
+ </Dialog>
68
+ );
69
+ }