@cosmicdrift/kumiko-bundled-features 0.148.0 → 0.149.1
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-mfa/__tests__/enable-flow.integration.test.ts +24 -1
- package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +137 -0
- package/src/auth-mfa/constants.ts +4 -0
- package/src/auth-mfa/db/queries.ts +15 -4
- package/src/auth-mfa/feature.ts +6 -1
- package/src/auth-mfa/handlers/enable-confirm.write.ts +3 -1
- package/src/auth-mfa/handlers/reencrypt.job.ts +54 -14
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +1 -1
- package/src/auth-mfa/handlers/status.query.ts +23 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/index.ts +6 -1
- package/src/auth-mfa/schema/user-mfa.ts +10 -3
- package/src/auth-mfa/web/i18n.ts +38 -0
- package/src/auth-mfa/web/index.ts +7 -0
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +70 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +16 -3
- package/src/auth-mfa/web/mfa-error-keys.ts +25 -0
- package/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +53 -0
- package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +69 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.149.1",
|
|
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.
|
|
118
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
119
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
120
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
117
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.149.1",
|
|
118
|
+
"@cosmicdrift/kumiko-framework": "0.149.1",
|
|
119
|
+
"@cosmicdrift/kumiko-headless": "0.149.1",
|
|
120
|
+
"@cosmicdrift/kumiko-renderer": "0.149.1",
|
|
121
|
+
"@cosmicdrift/kumiko-renderer-web": "0.149.1",
|
|
122
122
|
"@mollie/api-client": "^4.5.0",
|
|
123
123
|
"imapflow": "^1.3.3",
|
|
124
124
|
"mailparser": "^3.9.8",
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Regression test for a real crash found via manual browser testing in a
|
|
2
|
+
// consumer app (kumiko-studio) that mounts the full GDPR/user-data-rights
|
|
3
|
+
// stack: totpSecret and recoveryCodes are both `encrypted: true` +
|
|
4
|
+
// `userOwned` (schema/user-mfa.ts) — the SECOND layer (per-subject
|
|
5
|
+
// crypto-shredding, entity-field-encryption.ts's sibling pipeline) only
|
|
6
|
+
// activates when `configurePiiSubjectKms` has been called, which none of
|
|
7
|
+
// the other auth-mfa integration tests do. That gap let a real bug ship:
|
|
8
|
+
// recoveryCodes used to be a `createJsonbField` (an object, not a string),
|
|
9
|
+
// and `encryptPiiFieldValues` throws "PII field must be a string" for any
|
|
10
|
+
// non-string userOwned field. enable-confirm failed for EVERY real deployment
|
|
11
|
+
// with crypto-shredding configured, while every test here stayed green.
|
|
12
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
configurePiiSubjectKms,
|
|
15
|
+
InMemoryKmsAdapter,
|
|
16
|
+
resetPiiSubjectKmsForTests,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
18
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
+
import {
|
|
20
|
+
createTestUser,
|
|
21
|
+
setupTestStack,
|
|
22
|
+
type TestStack,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
|
+
unsafePushTables,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
|
+
import { createConfigFeature } from "../../config";
|
|
28
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
29
|
+
import { configValuesTable } from "../../config/table";
|
|
30
|
+
import { createUserFeature } from "../../user/feature";
|
|
31
|
+
import { userEntity } from "../../user/schema/user";
|
|
32
|
+
import { base32Decode } from "../base32";
|
|
33
|
+
import { AuthMfaHandlers } from "../constants";
|
|
34
|
+
import { createAuthMfaFeature } from "../feature";
|
|
35
|
+
import { userMfaEntity } from "../schema/user-mfa";
|
|
36
|
+
import { currentTotpCode } from "../totp";
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
|
|
40
|
+
const SETUP_TOKEN_SECRET = "test-setup-token-secret-do-not-use-in-prod";
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
const encryption = createTestEnvelopeCipher();
|
|
44
|
+
configureEntityFieldEncryption(encryption);
|
|
45
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
46
|
+
stack = await setupTestStack({
|
|
47
|
+
features: [
|
|
48
|
+
createConfigFeature(),
|
|
49
|
+
createUserFeature(),
|
|
50
|
+
createAuthMfaFeature({
|
|
51
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
52
|
+
issuer: "Kumiko Test",
|
|
53
|
+
challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
|
|
54
|
+
}),
|
|
55
|
+
],
|
|
56
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
57
|
+
});
|
|
58
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
59
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
60
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
await stack.cleanup();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The second, independent encryption layer — same activation recipe as
|
|
68
|
+
// crypto-shredding/__tests__/forget-subject.integration.test.ts.
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
resetPiiSubjectKmsForTests();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("with per-subject PII crypto-shredding active", () => {
|
|
78
|
+
test("enable-confirm succeeds and persists both totpSecret and recoveryCodes", async () => {
|
|
79
|
+
const user = createTestUser({ id: "pii-enable-1", roles: ["User"] });
|
|
80
|
+
|
|
81
|
+
const start = await stack.http.writeOk<{
|
|
82
|
+
setupToken: string;
|
|
83
|
+
otpauthUri: string;
|
|
84
|
+
recoveryCodes: string[];
|
|
85
|
+
}>(AuthMfaHandlers.enableStart, { accountLabel: "pii-enable@example.com" }, user);
|
|
86
|
+
|
|
87
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
88
|
+
const secret = base32Decode(secretParam);
|
|
89
|
+
|
|
90
|
+
const confirmed = await stack.http.writeOk<{ enabled: boolean }>(
|
|
91
|
+
AuthMfaHandlers.enableConfirm,
|
|
92
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
93
|
+
user,
|
|
94
|
+
);
|
|
95
|
+
expect(confirmed.enabled).toBe(true);
|
|
96
|
+
|
|
97
|
+
// recoveryCodes round-trips through both encryption layers correctly —
|
|
98
|
+
// a recovery code (not just the TOTP code) still authenticates.
|
|
99
|
+
const disableRes = await stack.http.writeOk<{ disabled: boolean }>(
|
|
100
|
+
AuthMfaHandlers.disable,
|
|
101
|
+
{ code: start.recoveryCodes[0] },
|
|
102
|
+
user,
|
|
103
|
+
);
|
|
104
|
+
expect(disableRes.disabled).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("regenerate-recovery re-encrypts the new codes without crashing", async () => {
|
|
108
|
+
const user = createTestUser({ id: "pii-regen-1", roles: ["User"] });
|
|
109
|
+
|
|
110
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
111
|
+
AuthMfaHandlers.enableStart,
|
|
112
|
+
{ accountLabel: "pii-regen@example.com" },
|
|
113
|
+
user,
|
|
114
|
+
);
|
|
115
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
116
|
+
const secret = base32Decode(secretParam);
|
|
117
|
+
await stack.http.writeOk(
|
|
118
|
+
AuthMfaHandlers.enableConfirm,
|
|
119
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
120
|
+
user,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const regenerated = await stack.http.writeOk<{ recoveryCodes: string[] }>(
|
|
124
|
+
AuthMfaHandlers.regenerateRecovery,
|
|
125
|
+
{ code: currentTotpCode(secret) },
|
|
126
|
+
user,
|
|
127
|
+
);
|
|
128
|
+
expect(regenerated.recoveryCodes).toHaveLength(8);
|
|
129
|
+
|
|
130
|
+
const disableRes = await stack.http.writeOk<{ disabled: boolean }>(
|
|
131
|
+
AuthMfaHandlers.disable,
|
|
132
|
+
{ code: regenerated.recoveryCodes[0] },
|
|
133
|
+
user,
|
|
134
|
+
);
|
|
135
|
+
expect(disableRes.disabled).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
5
|
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
5
6
|
|
|
6
7
|
export type UserMfaRow = {
|
|
@@ -17,10 +18,14 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
|
17
18
|
entityName: "user-mfa",
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
// `totpSecret`
|
|
21
|
-
// not
|
|
21
|
+
// `totpSecret`/`recoveryCodes` are `encrypted: true` — a raw fetchOne would
|
|
22
|
+
// return ciphertext, not plaintext. fetchOne only locates the row's id (no
|
|
22
23
|
// decryption needed for that); executor.detail() does the real, decrypting
|
|
23
|
-
// read the same way every other entity handler does.
|
|
24
|
+
// read the same way every other entity handler does. `recoveryCodes` is
|
|
25
|
+
// stored as a JSON string (schema/user-mfa.ts — both encryption layers need
|
|
26
|
+
// a string value) — parsed back into `{ hashes }` here, once, for every
|
|
27
|
+
// caller instead of each handler re-parsing it. A parse failure here means
|
|
28
|
+
// stored data is corrupt (post-decrypt, not user input) — fail loud.
|
|
24
29
|
export async function findUserMfaRow(db: TenantDb, user: SessionUser): Promise<UserMfaRow | null> {
|
|
25
30
|
const idLookup = await fetchOne<{ id: string }>(db, userMfaTable, {
|
|
26
31
|
userId: user.id,
|
|
@@ -28,7 +33,13 @@ export async function findUserMfaRow(db: TenantDb, user: SessionUser): Promise<U
|
|
|
28
33
|
});
|
|
29
34
|
if (!idLookup) return null;
|
|
30
35
|
const detail = await executor.detail({ id: idLookup.id }, user, db);
|
|
36
|
+
if (!detail) return null;
|
|
31
37
|
// @cast-boundary engine-payload — executor.detail returns a decrypted,
|
|
32
38
|
// untyped Record; the userMfa entity's own field shape narrows it.
|
|
33
|
-
|
|
39
|
+
const row = detail as Omit<UserMfaRow, "recoveryCodes"> & { recoveryCodes: string };
|
|
40
|
+
const recoveryCodes = parseJsonOrThrow<{ hashes: readonly string[] }>(
|
|
41
|
+
row.recoveryCodes,
|
|
42
|
+
"user-mfa.recoveryCodes",
|
|
43
|
+
);
|
|
44
|
+
return { ...row, recoveryCodes };
|
|
34
45
|
}
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -46,7 +46,9 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
|
|
|
46
46
|
{
|
|
47
47
|
userId: event.user.id,
|
|
48
48
|
totpSecret: verify.payload.totpSecretBase32,
|
|
49
|
-
|
|
49
|
+
// Stored as a JSON string — see schema/user-mfa.ts (recoveryCodes
|
|
50
|
+
// is an encrypted+userOwned text field, both layers need a string).
|
|
51
|
+
recoveryCodes: JSON.stringify({ hashes: verify.payload.recoveryCodeHashes }),
|
|
50
52
|
enabledAt: Temporal.Now.instant(),
|
|
51
53
|
lastUsedAt: null,
|
|
52
54
|
},
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
// KEK-rotation job for userMfa.totpSecret (entity-field
|
|
2
|
-
// MasterKeyProvider as secrets/config — see
|
|
3
|
-
// directly on config/handlers/reencrypt.job.ts:
|
|
4
|
-
// stores a StoredEnvelope JSON string per value (no
|
|
5
|
-
// column, unlike secrets' DEK-wrap model), so rotation
|
|
6
|
-
// the OLD key, then let the executor's own write-path
|
|
7
|
-
// provider.currentVersion() — the same auto-encrypt every
|
|
8
|
-
// write already goes through (see enable-confirm.write.ts: it
|
|
9
|
-
// PLAINTEXT to executor.create, never a manually-built envelope).
|
|
1
|
+
// KEK-rotation job for userMfa.totpSecret + recoveryCodes (entity-field
|
|
2
|
+
// encryption, same MasterKeyProvider as secrets/config — see
|
|
3
|
+
// schema/user-mfa.ts). Modeled directly on config/handlers/reencrypt.job.ts:
|
|
4
|
+
// entity-field encryption stores a StoredEnvelope JSON string per value (no
|
|
5
|
+
// separate kekVersion column, unlike secrets' DEK-wrap model), so rotation
|
|
6
|
+
// means decrypt under the OLD key, then let the executor's own write-path
|
|
7
|
+
// re-encrypt under provider.currentVersion() — the same auto-encrypt every
|
|
8
|
+
// enable-confirm write already goes through (see enable-confirm.write.ts: it
|
|
9
|
+
// passes PLAINTEXT to executor.create, never a manually-built envelope).
|
|
10
|
+
//
|
|
11
|
+
// Both fields also carry `userOwned` — the executor's own encryptForStorage
|
|
12
|
+
// wraps them PII(envelope(plaintext)) (see event-store-executor-context.ts).
|
|
13
|
+
// This job reads raw rows via selectMany (bypassing the executor's
|
|
14
|
+
// decryptForRead), so it must peel that PII layer itself before the envelope
|
|
15
|
+
// cipher ever sees the value, mirroring decryptForRead's PII-then-envelope
|
|
16
|
+
// order — an envelope decrypt on a still-PII-wrapped string throws
|
|
17
|
+
// "legacy single-key format" instead of rotating anything.
|
|
10
18
|
//
|
|
11
19
|
// Idempotent: re-running skips rows already on the current kekVersion.
|
|
12
20
|
// Every write is a normal executor.update — after a full run even a
|
|
@@ -16,6 +24,11 @@
|
|
|
16
24
|
// projection instead of appending a real `.updated` event).
|
|
17
25
|
|
|
18
26
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
27
|
+
import {
|
|
28
|
+
collectPiiSubjectFields,
|
|
29
|
+
configuredPiiSubjectKms,
|
|
30
|
+
decryptPiiFieldValues,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
19
32
|
import {
|
|
20
33
|
configuredEntityFieldEncryption,
|
|
21
34
|
createEventStoreExecutor,
|
|
@@ -37,6 +50,8 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
|
37
50
|
entityName: "user-mfa",
|
|
38
51
|
});
|
|
39
52
|
|
|
53
|
+
const piiFieldNames = collectPiiSubjectFields(userMfaEntity);
|
|
54
|
+
|
|
40
55
|
export type MfaReencryptJobPayload = {
|
|
41
56
|
readonly batchSize?: number;
|
|
42
57
|
readonly maxDurationMs?: number;
|
|
@@ -91,6 +106,9 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
91
106
|
});
|
|
92
107
|
}
|
|
93
108
|
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
109
|
+
// Undefined = per-subject crypto-shredding isn't configured for this app —
|
|
110
|
+
// both fields are then plain envelope strings, no PII layer to peel.
|
|
111
|
+
const piiKms = configuredPiiSubjectKms();
|
|
94
112
|
|
|
95
113
|
const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
96
114
|
const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
|
|
@@ -113,6 +131,7 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
113
131
|
tenantId: string;
|
|
114
132
|
version: number;
|
|
115
133
|
totpSecret: string;
|
|
134
|
+
recoveryCodes: string;
|
|
116
135
|
};
|
|
117
136
|
|
|
118
137
|
let alreadyCurrent = 0;
|
|
@@ -131,22 +150,43 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
async function migrateRow(row: UserMfaRow): Promise<"migrated" | "skipped" | "failed"> {
|
|
134
|
-
|
|
153
|
+
const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
|
|
154
|
+
|
|
155
|
+
// Peel the PII layer first (if active) so needsReencrypt/cipher.decrypt
|
|
156
|
+
// below only ever see envelope strings, same order as decryptForRead.
|
|
157
|
+
let envelopeValues: { totpSecret: string; recoveryCodes: string } = row;
|
|
158
|
+
if (piiKms) {
|
|
159
|
+
const unwrapped = await decryptPiiFieldValues(row, piiFieldNames, piiKms, {
|
|
160
|
+
requestId: "auth-mfa-reencrypt-job",
|
|
161
|
+
tenantId,
|
|
162
|
+
});
|
|
163
|
+
envelopeValues = unwrapped as { totpSecret: string; recoveryCodes: string }; // @cast-boundary engine-payload
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const totpNeedsRotation = needsReencrypt(envelopeValues.totpSecret, targetVersion);
|
|
167
|
+
const recoveryNeedsRotation = needsReencrypt(envelopeValues.recoveryCodes, targetVersion);
|
|
168
|
+
if (!totpNeedsRotation && !recoveryNeedsRotation) {
|
|
135
169
|
alreadyCurrent++;
|
|
136
170
|
return "skipped";
|
|
137
171
|
}
|
|
138
172
|
|
|
139
|
-
const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
|
|
140
173
|
// decrypt failure (missing legacy key, unknown kekVersion, tamper)
|
|
141
174
|
// throws → counted as failed via onRowError; the row stays untouched.
|
|
142
|
-
const
|
|
175
|
+
const changes: Record<string, string> = {};
|
|
176
|
+
if (totpNeedsRotation) {
|
|
177
|
+
changes["totpSecret"] = await cipher.decrypt(envelopeValues.totpSecret, { tenantId });
|
|
178
|
+
}
|
|
179
|
+
if (recoveryNeedsRotation) {
|
|
180
|
+
changes["recoveryCodes"] = await cipher.decrypt(envelopeValues.recoveryCodes, { tenantId });
|
|
181
|
+
}
|
|
143
182
|
|
|
144
183
|
const actor: SessionUser = { id: "system", tenantId, roles: SYSTEM_ROLES };
|
|
145
184
|
// PLAINTEXT here, not a manually-built envelope — the executor's own
|
|
146
185
|
// write-path (encryptForStorage) re-encrypts under the CURRENT
|
|
147
|
-
// injected cipher/KEK
|
|
186
|
+
// injected cipher/KEK (and re-wraps the PII layer under the same,
|
|
187
|
+
// unrotated subject DEK), exactly like every other write to this field.
|
|
148
188
|
const result = await executor.update(
|
|
149
|
-
{ id: row.id, version: row.version, changes
|
|
189
|
+
{ id: row.id, version: row.version, changes },
|
|
150
190
|
actor,
|
|
151
191
|
tdbFor(tenantId),
|
|
152
192
|
);
|
|
@@ -44,7 +44,7 @@ export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions)
|
|
|
44
44
|
{
|
|
45
45
|
id: row.id,
|
|
46
46
|
version: row.version,
|
|
47
|
-
changes: { recoveryCodes: { hashes: newHashes } },
|
|
47
|
+
changes: { recoveryCodes: JSON.stringify({ hashes: newHashes }) },
|
|
48
48
|
},
|
|
49
49
|
event.user,
|
|
50
50
|
ctx.db,
|
|
@@ -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
|
+
});
|
|
@@ -94,7 +94,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
94
94
|
{
|
|
95
95
|
id: row.id,
|
|
96
96
|
version: row.version,
|
|
97
|
-
changes: { recoveryCodes: { hashes: verify.remainingHashes } },
|
|
97
|
+
changes: { recoveryCodes: JSON.stringify({ hashes: verify.remainingHashes }) },
|
|
98
98
|
},
|
|
99
99
|
scopedUser,
|
|
100
100
|
scopedDb,
|
package/src/auth-mfa/index.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export { base32Decode } from "./base32";
|
|
2
2
|
export { type MfaRequiredPolicy, mfaRequiredConfigHandle } from "./config";
|
|
3
|
-
export {
|
|
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,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
createEntity,
|
|
4
|
-
createJsonbField,
|
|
5
4
|
createTextField,
|
|
6
5
|
createTimestampField,
|
|
7
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -27,10 +26,18 @@ export const userMfaEntity = createEntity({
|
|
|
27
26
|
encrypted: true,
|
|
28
27
|
userOwned: { ownerField: "userId" },
|
|
29
28
|
}),
|
|
30
|
-
// { hashes: string[] }
|
|
29
|
+
// JSON-stringified `{ hashes: string[] }` (argon2id hashes of unredeemed
|
|
30
|
+
// recovery codes) — both encryption layers (entity-field envelope +
|
|
31
|
+
// per-subject crypto-shred, same as totpSecret above) require a string
|
|
32
|
+
// value, so callers JSON.stringify on write and JSON.parse on read
|
|
33
|
+
// (see db/queries.ts's findUserMfaRow) rather than storing jsonb here.
|
|
31
34
|
// A redeemed code's hash is removed from the array (see disable/regen
|
|
32
35
|
// handlers), so array length also IS the remaining-codes count.
|
|
33
|
-
recoveryCodes:
|
|
36
|
+
recoveryCodes: createTextField({
|
|
37
|
+
required: true,
|
|
38
|
+
encrypted: true,
|
|
39
|
+
userOwned: { ownerField: "userId" },
|
|
40
|
+
}),
|
|
34
41
|
enabledAt: createTimestampField({ required: true }),
|
|
35
42
|
lastUsedAt: createTimestampField(),
|
|
36
43
|
},
|
package/src/auth-mfa/web/i18n.ts
CHANGED
|
@@ -35,6 +35,25 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
35
35
|
"auth.mfa.enable.cancel": "Abbrechen",
|
|
36
36
|
"auth.mfa.enable.confirm": "Aktivieren",
|
|
37
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",
|
|
38
57
|
},
|
|
39
58
|
en: {
|
|
40
59
|
"screen:auth-mfa-enable.title": "Two-factor authentication",
|
|
@@ -65,6 +84,25 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
65
84
|
"auth.mfa.enable.cancel": "Cancel",
|
|
66
85
|
"auth.mfa.enable.confirm": "Enable",
|
|
67
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",
|
|
68
106
|
},
|
|
69
107
|
};
|
|
70
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;
|
|
@@ -35,9 +36,20 @@ function extractSecret(otpauthUri: string): string {
|
|
|
35
36
|
return new URLSearchParams(query).get("secret") ?? "";
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
export type MfaEnableScreenProps = {
|
|
39
|
+
export type MfaEnableScreenProps = {
|
|
40
|
+
readonly embedded?: boolean;
|
|
41
|
+
// Fired once enable-confirm succeeds — MfaEnableScreen has no query of its
|
|
42
|
+
// own to invalidate, so a host screen composing it alongside other MFA
|
|
43
|
+
// state (e.g. a status query gating which section renders) needs this to
|
|
44
|
+
// know when to refetch/swap views instead of leaving the success banner
|
|
45
|
+
// as a dead end.
|
|
46
|
+
readonly onEnabled?: () => void;
|
|
47
|
+
};
|
|
39
48
|
|
|
40
|
-
export function MfaEnableScreen({
|
|
49
|
+
export function MfaEnableScreen({
|
|
50
|
+
embedded = false,
|
|
51
|
+
onEnabled,
|
|
52
|
+
}: MfaEnableScreenProps = {}): ReactNode {
|
|
41
53
|
const t = useTranslation();
|
|
42
54
|
const { Button, Banner, Field, Input, Section, Heading } = usePrimitives();
|
|
43
55
|
const dispatcher = useDispatcher();
|
|
@@ -88,6 +100,7 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
|
|
|
88
100
|
}
|
|
89
101
|
setEnabled(true);
|
|
90
102
|
setSetup(null);
|
|
103
|
+
onEnabled?.();
|
|
91
104
|
};
|
|
92
105
|
|
|
93
106
|
const content = (
|
|
@@ -95,7 +108,7 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
|
|
|
95
108
|
<Heading>{t("auth.mfa.enable.title")}</Heading>
|
|
96
109
|
|
|
97
110
|
{enabled && <Banner variant="info">{t("auth.mfa.enable.success")}</Banner>}
|
|
98
|
-
{error !== null && <Banner variant="error">{t(
|
|
111
|
+
{error !== null && <Banner variant="error">{t(mfaManageErrorKey(error))}</Banner>}
|
|
99
112
|
|
|
100
113
|
{!setup && !enabled && (
|
|
101
114
|
<Section
|
|
@@ -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
|
+
}
|