@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.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 +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -7
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
- package/src/feature-toggles/feature.ts +24 -4
- package/src/feature-toggles/handlers/registered.query.ts +27 -16
- package/src/feature-toggles/i18n.ts +5 -0
- package/src/feature-toggles/web/client-plugin.tsx +1 -5
- package/src/feature-toggles/web/index.ts +0 -1
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +7 -6
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/secrets/handlers/delete.write.ts +0 -4
- package/src/secrets/handlers/list.query.ts +0 -4
- package/src/secrets/handlers/set.write.ts +0 -4
- package/src/secrets/index.ts +3 -3
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/__tests__/feature.test.ts +18 -5
- package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
- package/src/tags/constants.ts +3 -2
- package/src/tags/entity.ts +2 -0
- package/src/tags/feature.ts +20 -11
- package/src/tags/handlers/create-tag.write.ts +5 -2
- package/src/tags/handlers/delete-tag.write.ts +5 -2
- package/src/tags/handlers/update-tag.write.ts +5 -2
- package/src/tags/i18n.ts +4 -0
- package/src/tags/index.ts +2 -0
- package/src/tags/screens.ts +66 -0
- package/src/tags/web/client-plugin.tsx +1 -6
- package/src/tags/web/index.ts +1 -0
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +3 -1
- package/src/tenant/handlers/update-member-roles.write.ts +16 -2
- package/src/tenant/i18n.ts +2 -3
- package/src/tenant/screens.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +23 -6
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/i18n.ts +0 -6
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
- package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.221.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>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.221.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.221.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.221.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.221.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.221.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.221.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
"devDependencies": {
|
|
161
161
|
"@testing-library/user-event": "^14.6.1",
|
|
162
162
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
164
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.221.0",
|
|
164
|
+
"@cosmicdrift/kumiko-locale-es": "0.221.0"
|
|
165
165
|
}
|
|
166
166
|
}
|
|
@@ -294,7 +294,7 @@ describe("access-invalidation event consumer", () => {
|
|
|
294
294
|
{
|
|
295
295
|
id: userId,
|
|
296
296
|
version: detail["version"],
|
|
297
|
-
changes: { roles:
|
|
297
|
+
changes: { roles: ["SystemAdmin"] },
|
|
298
298
|
},
|
|
299
299
|
admin,
|
|
300
300
|
);
|
|
@@ -307,6 +307,7 @@ describe("access-invalidation event consumer", () => {
|
|
|
307
307
|
const sessionRow = await fetchOne<{ revokedAt: unknown }>(stack.db, userSessionTable, {
|
|
308
308
|
id: sid,
|
|
309
309
|
});
|
|
310
|
+
expect(sessionRow).toBeTruthy();
|
|
310
311
|
expect(sessionRow?.revokedAt).not.toBeNull();
|
|
311
312
|
});
|
|
312
313
|
});
|
|
@@ -21,7 +21,8 @@ import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-w
|
|
|
21
21
|
import { render, waitFor } from "@testing-library/react";
|
|
22
22
|
import type { ReactNode } from "react";
|
|
23
23
|
import { TenantQueries } from "../../tenant/constants";
|
|
24
|
-
import { AuditQueries } from "../constants";
|
|
24
|
+
import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries } from "../constants";
|
|
25
|
+
import { AuditLogDetailScreen } from "../web/audit-log-detail-screen";
|
|
25
26
|
import { AuditLogScreen } from "../web/audit-log-screen";
|
|
26
27
|
import { defaultTranslations } from "../web/i18n";
|
|
27
28
|
|
|
@@ -124,14 +125,32 @@ const MEMBERS: readonly MemberRowFixture[] = [
|
|
|
124
125
|
{ userId: "user-2", email: "bob@example.com", displayName: null },
|
|
125
126
|
];
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
type AuditDetailFixture = AuditRowFixture & { readonly metadata: Record<string, unknown> };
|
|
129
|
+
|
|
130
|
+
function detailFixtureFor(row: AuditRowFixture): AuditDetailFixture {
|
|
131
|
+
return { ...row, metadata: {} };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type MembersResult =
|
|
135
|
+
| { readonly isSuccess: true; readonly data: readonly MemberRowFixture[] }
|
|
136
|
+
| { readonly isSuccess: false; readonly error: { readonly message: string } };
|
|
137
|
+
|
|
138
|
+
function makeDispatcher(
|
|
139
|
+
options: {
|
|
140
|
+
readonly detail?: AuditDetailFixture | null;
|
|
141
|
+
readonly membersResult?: MembersResult;
|
|
142
|
+
} = {},
|
|
143
|
+
): Dispatcher {
|
|
128
144
|
const statusStore = createStore<DispatcherStatus>("online");
|
|
129
145
|
const query = (async (type: string) => {
|
|
130
146
|
if (type === AuditQueries.list) {
|
|
131
147
|
return { isSuccess: true, data: { rows: AUDIT_ROWS, nextBefore: null } };
|
|
132
148
|
}
|
|
149
|
+
if (type === AuditQueries.details) {
|
|
150
|
+
return { isSuccess: true, data: options.detail ?? null };
|
|
151
|
+
}
|
|
133
152
|
if (type === TenantQueries.members) {
|
|
134
|
-
return { isSuccess: true, data: MEMBERS };
|
|
153
|
+
return options.membersResult ?? { isSuccess: true, data: MEMBERS };
|
|
135
154
|
}
|
|
136
155
|
return { isSuccess: true, data: null };
|
|
137
156
|
}) as unknown as Dispatcher["query"];
|
|
@@ -146,7 +165,12 @@ function makeDispatcher(): Dispatcher {
|
|
|
146
165
|
} as unknown as Dispatcher; // @cast-boundary test-stub
|
|
147
166
|
}
|
|
148
167
|
|
|
149
|
-
function
|
|
168
|
+
function renderWithProviders(
|
|
169
|
+
ui: ReactNode,
|
|
170
|
+
options: { readonly nav?: NavApi; readonly dispatcher?: Dispatcher } = {},
|
|
171
|
+
): ReturnType<typeof render> {
|
|
172
|
+
const nav = options.nav ?? stubNav;
|
|
173
|
+
const dispatcher = options.dispatcher ?? makeDispatcher();
|
|
150
174
|
const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
151
175
|
<TokensProvider value={stubTokens}>
|
|
152
176
|
<LocaleProvider
|
|
@@ -154,16 +178,25 @@ function renderScreen(): ReturnType<typeof render> {
|
|
|
154
178
|
fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
|
|
155
179
|
>
|
|
156
180
|
<PrimitivesProvider value={defaultPrimitives}>
|
|
157
|
-
<NavProvider value={
|
|
181
|
+
<NavProvider value={nav}>
|
|
158
182
|
<LiveEventsProvider value={stubLiveEvents}>
|
|
159
|
-
<DispatcherProvider dispatcher={
|
|
183
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
160
184
|
</LiveEventsProvider>
|
|
161
185
|
</NavProvider>
|
|
162
186
|
</PrimitivesProvider>
|
|
163
187
|
</LocaleProvider>
|
|
164
188
|
</TokensProvider>
|
|
165
189
|
);
|
|
166
|
-
return render(
|
|
190
|
+
return render(ui, { wrapper });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderScreen(dispatcher?: Dispatcher): ReturnType<typeof render> {
|
|
194
|
+
return renderWithProviders(<AuditLogScreen />, { dispatcher });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderDetailScreen(entityId: string, dispatcher: Dispatcher): ReturnType<typeof render> {
|
|
198
|
+
const nav: NavApi = { ...stubNav, route: { screenId: AUDIT_LOG_DETAIL_SCREEN_ID, entityId } };
|
|
199
|
+
return renderWithProviders(<AuditLogDetailScreen />, { nav, dispatcher });
|
|
167
200
|
}
|
|
168
201
|
|
|
169
202
|
describe("AuditLogScreen — actor name resolution", () => {
|
|
@@ -226,3 +259,44 @@ describe("AuditLogScreen — actor name resolution", () => {
|
|
|
226
259
|
expect(view.container.textContent).not.toContain("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
|
|
227
260
|
});
|
|
228
261
|
});
|
|
262
|
+
|
|
263
|
+
describe("AuditLogScreen — members query failure", () => {
|
|
264
|
+
test("members query returns isSuccess:false → list still renders, no crash", async () => {
|
|
265
|
+
const dispatcher = makeDispatcher({
|
|
266
|
+
membersResult: { isSuccess: false, error: { message: "members unavailable" } },
|
|
267
|
+
});
|
|
268
|
+
const view = renderScreen(dispatcher);
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
const cell = view.queryByTestId("cell-1-type");
|
|
271
|
+
if (cell === null) throw new Error("table not rendered yet");
|
|
272
|
+
});
|
|
273
|
+
expect(view.getByTestId("cell-1-type").textContent).toBe("widget.created");
|
|
274
|
+
expect(view.getByTestId("cell-1-actor").textContent).toBe("");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("AuditLogDetailScreen — actor name resolution", () => {
|
|
279
|
+
test("known member with displayName → detail actor field shows the display name", async () => {
|
|
280
|
+
const row = AUDIT_ROWS[0];
|
|
281
|
+
if (row === undefined) throw new Error("fixture missing");
|
|
282
|
+
const dispatcher = makeDispatcher({ detail: detailFixtureFor(row) });
|
|
283
|
+
const view = renderDetailScreen(row.id, dispatcher);
|
|
284
|
+
await waitFor(() => {
|
|
285
|
+
const cell = view.queryByTestId("audit-detail-actor");
|
|
286
|
+
if (cell === null) throw new Error("actor field not rendered yet");
|
|
287
|
+
});
|
|
288
|
+
expect(view.getByTestId("audit-detail-actor").textContent).toBe("Alice Example");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("known member without displayName → detail actor field falls back to email", async () => {
|
|
292
|
+
const row = AUDIT_ROWS[1];
|
|
293
|
+
if (row === undefined) throw new Error("fixture missing");
|
|
294
|
+
const dispatcher = makeDispatcher({ detail: detailFixtureFor(row) });
|
|
295
|
+
const view = renderDetailScreen(row.id, dispatcher);
|
|
296
|
+
await waitFor(() => {
|
|
297
|
+
const cell = view.queryByTestId("audit-detail-actor");
|
|
298
|
+
if (cell === null) throw new Error("actor field not rendered yet");
|
|
299
|
+
});
|
|
300
|
+
expect(view.getByTestId("audit-detail-actor").textContent).toBe("bob@example.com");
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SYSTEM_ACTOR_ID } from "../constants";
|
|
3
|
+
|
|
4
|
+
describe("audit constants", () => {
|
|
5
|
+
test("SYSTEM_ACTOR_ID matches the event-store system user id", () => {
|
|
6
|
+
expect(SYSTEM_ACTOR_ID).toBe("system");
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -34,7 +34,15 @@ type State =
|
|
|
34
34
|
| { readonly kind: "loading" }
|
|
35
35
|
| { readonly kind: "missing" }
|
|
36
36
|
| { readonly kind: "error"; readonly message: string }
|
|
37
|
-
| {
|
|
37
|
+
| {
|
|
38
|
+
readonly kind: "ready";
|
|
39
|
+
readonly event: AuditDetail;
|
|
40
|
+
/** Resolved member display string, or "" if unknown. System actor is
|
|
41
|
+
* flagged separately so the translated label is formed in render
|
|
42
|
+
* (avoids refetch on locale change — fw#2297). */
|
|
43
|
+
readonly memberLabel: string;
|
|
44
|
+
readonly isSystemActor: boolean;
|
|
45
|
+
};
|
|
38
46
|
|
|
39
47
|
export function AuditLogDetailScreen(): ReactNode {
|
|
40
48
|
const t = useTranslation();
|
|
@@ -60,21 +68,24 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
60
68
|
return;
|
|
61
69
|
}
|
|
62
70
|
const event = res.data;
|
|
63
|
-
|
|
71
|
+
if (event.createdBy === SYSTEM_ACTOR_ID) {
|
|
72
|
+
setState({ kind: "ready", event, memberLabel: "", isSystemActor: true });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Single-user filter — do not decrypt the whole tenant roster for one name.
|
|
76
|
+
const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {
|
|
77
|
+
userId: event.createdBy,
|
|
78
|
+
});
|
|
64
79
|
const member = membersRes.isSuccess
|
|
65
80
|
? membersRes.data.find((m) => m.userId === event.createdBy)
|
|
66
81
|
: undefined;
|
|
67
82
|
setState({
|
|
68
83
|
kind: "ready",
|
|
69
84
|
event,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
? t("audit.log.actor.system")
|
|
73
|
-
: member
|
|
74
|
-
? (member.displayName ?? member.email ?? "")
|
|
75
|
-
: "",
|
|
85
|
+
memberLabel: member ? (member.displayName ?? member.email ?? "") : "",
|
|
86
|
+
isSystemActor: false,
|
|
76
87
|
});
|
|
77
|
-
}, [dispatcher, eventId
|
|
88
|
+
}, [dispatcher, eventId]);
|
|
78
89
|
|
|
79
90
|
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
80
91
|
useEffect(() => {
|
|
@@ -83,7 +94,7 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
83
94
|
|
|
84
95
|
if (state.kind === "loading") {
|
|
85
96
|
return (
|
|
86
|
-
<FormScreenShell testId="audit-log-detail-screen">
|
|
97
|
+
<FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
|
|
87
98
|
<Text variant="small">{t("audit.log.detail.loading")}</Text>
|
|
88
99
|
</FormScreenShell>
|
|
89
100
|
);
|
|
@@ -91,7 +102,7 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
91
102
|
|
|
92
103
|
if (state.kind === "missing") {
|
|
93
104
|
return (
|
|
94
|
-
<FormScreenShell testId="audit-log-detail-screen">
|
|
105
|
+
<FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
|
|
95
106
|
<Banner variant="error">{t("audit.log.detail.missing")}</Banner>
|
|
96
107
|
</FormScreenShell>
|
|
97
108
|
);
|
|
@@ -99,16 +110,21 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
99
110
|
|
|
100
111
|
if (state.kind === "error") {
|
|
101
112
|
return (
|
|
102
|
-
<FormScreenShell testId="audit-log-detail-screen">
|
|
113
|
+
<FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
|
|
103
114
|
<Banner variant="error">{state.message}</Banner>
|
|
104
115
|
</FormScreenShell>
|
|
105
116
|
);
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
const { event,
|
|
119
|
+
const { event, memberLabel, isSystemActor } = state;
|
|
120
|
+
const actorName = isSystemActor ? t("audit.log.actor.system") : memberLabel;
|
|
109
121
|
|
|
110
122
|
return (
|
|
111
|
-
<FormScreenShell
|
|
123
|
+
<FormScreenShell
|
|
124
|
+
testId="audit-log-detail-screen"
|
|
125
|
+
maxWidth="3xl"
|
|
126
|
+
className="flex flex-col gap-6"
|
|
127
|
+
>
|
|
112
128
|
<Card slots={{ title: event.type }}>
|
|
113
129
|
<dl className="grid gap-3 text-sm">
|
|
114
130
|
<div>
|
|
@@ -70,17 +70,31 @@ export function AuditLogScreen(): ReactNode {
|
|
|
70
70
|
// mid-session and refetching on every "older" click is wasted work.
|
|
71
71
|
const membersRef = useRef<Promise<ReadonlyMap<string, string>> | null>(null);
|
|
72
72
|
|
|
73
|
+
// Tenant/dispatcher identity can change without remounting — drop the cache.
|
|
74
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: reset is keyed on dispatcher identity, not a read inside the effect
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
membersRef.current = null;
|
|
77
|
+
}, [dispatcher]);
|
|
78
|
+
|
|
73
79
|
const load = useCallback(
|
|
74
80
|
async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
|
|
75
81
|
setState({ kind: "loading" });
|
|
76
82
|
const f = overrideFilters ?? filtersRef.current;
|
|
77
|
-
membersRef.current
|
|
78
|
-
.query<readonly MemberRow[]>(TenantQueries.members, {})
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
if (!membersRef.current) {
|
|
84
|
+
const p = dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {}).then((res) => {
|
|
85
|
+
if (!res.isSuccess) {
|
|
86
|
+
// Soft failure: clear so a later filter/page retry can refetch
|
|
87
|
+
// instead of freezing an empty actor column for the session.
|
|
88
|
+
if (membersRef.current === p) membersRef.current = null;
|
|
89
|
+
return new Map<string, string>();
|
|
90
|
+
}
|
|
91
|
+
return new Map(res.data.map((m) => [m.userId, m.displayName ?? m.email ?? ""] as const));
|
|
92
|
+
});
|
|
93
|
+
membersRef.current = p;
|
|
94
|
+
void p.catch(() => {
|
|
95
|
+
if (membersRef.current === p) membersRef.current = null;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
84
98
|
const [res, names] = await Promise.all([
|
|
85
99
|
dispatcher.query<AuditResponse>(AuditQueries.list, {
|
|
86
100
|
limit: 50,
|
|
@@ -78,6 +78,7 @@ async function seedLoginUser(opts: {
|
|
|
78
78
|
password: string;
|
|
79
79
|
tenantId?: TenantId;
|
|
80
80
|
roles?: string[];
|
|
81
|
+
locale?: string;
|
|
81
82
|
}): Promise<{ id: string; tenantId: TenantId }> {
|
|
82
83
|
const hash = await hashPassword(opts.password);
|
|
83
84
|
const created = await stack.http.writeOk<{ id: string }>(
|
|
@@ -86,6 +87,7 @@ async function seedLoginUser(opts: {
|
|
|
86
87
|
email: opts.email,
|
|
87
88
|
passwordHash: hash,
|
|
88
89
|
displayName: opts.email.split("@")[0] ?? "user",
|
|
90
|
+
locale: opts.locale,
|
|
89
91
|
},
|
|
90
92
|
systemAdmin,
|
|
91
93
|
);
|
|
@@ -134,6 +136,33 @@ describe("scenario 1: login success", () => {
|
|
|
134
136
|
},
|
|
135
137
|
);
|
|
136
138
|
expect(meRes.status).toBe(200);
|
|
139
|
+
|
|
140
|
+
// fw#2333 — a user without a stored locale must not carry a locale
|
|
141
|
+
// claim at all (not null, not "").
|
|
142
|
+
const payload = await stack.jwt.verify(body.token as string);
|
|
143
|
+
expect(payload.locale).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("correct credentials with a stored locale → JWT carries the locale claim", async () => {
|
|
147
|
+
const seed = await seedLoginUser({
|
|
148
|
+
email: "good-locale@example.com",
|
|
149
|
+
password: "correct-horse-battery",
|
|
150
|
+
roles: ["User"],
|
|
151
|
+
locale: "de-DE",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
155
|
+
email: "good-locale@example.com",
|
|
156
|
+
password: "correct-horse-battery",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(res.status).toBe(200);
|
|
160
|
+
const body = await res.json();
|
|
161
|
+
expect(body.token).toBeTypeOf("string");
|
|
162
|
+
|
|
163
|
+
const payload = await stack.jwt.verify(body.token as string);
|
|
164
|
+
expect(payload.locale).toBe("de-DE");
|
|
165
|
+
expect(payload.sub).toBe(seed.id);
|
|
137
166
|
});
|
|
138
167
|
});
|
|
139
168
|
|
package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts
CHANGED
|
@@ -166,6 +166,16 @@ function aliceSession(): SessionUser {
|
|
|
166
166
|
return { id: aliceId, tenantId: TENANT_A_ID, roles: ["Admin"] };
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// auth-mfa:write:verify runs pre-session (access: { roles: ["all"] }) —
|
|
170
|
+
// dispatched here the same way the framework's /api/auth/mfa/verify route
|
|
171
|
+
// would, with a guest identity. The handler derives everything it needs
|
|
172
|
+
// from the challenge token, not from this actor.
|
|
173
|
+
const GUEST: SessionUser = {
|
|
174
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
175
|
+
tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
|
|
176
|
+
roles: ["all"],
|
|
177
|
+
};
|
|
178
|
+
|
|
169
179
|
async function inviteEmail(email: string, role: string): Promise<string> {
|
|
170
180
|
await stack.http.writeOk(AuthHandlers.inviteCreate, { email, role }, aliceSession());
|
|
171
181
|
const sent = emailTransport.sent.at(-1);
|
|
@@ -230,6 +240,79 @@ describe("invite-accept-with-login: MFA gate", () => {
|
|
|
230
240
|
// No session — a challenge, not a JWT.
|
|
231
241
|
expect(body.token).toBeUndefined();
|
|
232
242
|
});
|
|
243
|
+
|
|
244
|
+
// fw#2333 — completing the challenge via auth-mfa:write:verify must carry
|
|
245
|
+
// the invitee's locale into the minted session, same as a plain login.
|
|
246
|
+
async function acceptWithLoginThenVerifyMfa(opts: {
|
|
247
|
+
actorId: number;
|
|
248
|
+
email: string;
|
|
249
|
+
password: string;
|
|
250
|
+
locale?: string;
|
|
251
|
+
}): Promise<SessionUser> {
|
|
252
|
+
const mfaActor = createTestUser({ id: opts.actorId, tenantId: TENANT_A_ID, roles: ["User"] });
|
|
253
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
254
|
+
AuthMfaHandlers.enableStart,
|
|
255
|
+
{ accountLabel: opts.email },
|
|
256
|
+
mfaActor,
|
|
257
|
+
);
|
|
258
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
259
|
+
const secret = base32Decode(secretParam);
|
|
260
|
+
await stack.http.writeOk(
|
|
261
|
+
AuthMfaHandlers.enableConfirm,
|
|
262
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
263
|
+
mfaActor,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
await seedRow(stack.db, userTable, {
|
|
267
|
+
id: mfaActor.id,
|
|
268
|
+
tenantId: TENANT_A_ID,
|
|
269
|
+
email: opts.email,
|
|
270
|
+
passwordHash: await hashPassword(opts.password),
|
|
271
|
+
displayName: "MFA Invitee",
|
|
272
|
+
...(opts.locale !== undefined && { locale: opts.locale }),
|
|
273
|
+
emailVerified: true,
|
|
274
|
+
roles: "[]",
|
|
275
|
+
status: USER_STATUS.Active,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const token = await inviteEmail(opts.email, "Editor");
|
|
279
|
+
const acceptRes = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
280
|
+
token,
|
|
281
|
+
email: opts.email,
|
|
282
|
+
password: opts.password,
|
|
283
|
+
});
|
|
284
|
+
expect(acceptRes.status).toBe(200);
|
|
285
|
+
const acceptBody = (await acceptRes.json()) as { challengeToken?: string };
|
|
286
|
+
if (!acceptBody.challengeToken) {
|
|
287
|
+
throw new Error("invite-accept-with-login did not return a challenge token");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const verified = await stack.http.writeOk<{ session: SessionUser }>(
|
|
291
|
+
AuthMfaHandlers.verify,
|
|
292
|
+
{ challengeToken: acceptBody.challengeToken, code: currentTotpCode(secret) },
|
|
293
|
+
GUEST,
|
|
294
|
+
);
|
|
295
|
+
return verified.session;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
test("completing the mfa/verify challenge carries the invitee's locale into the session", async () => {
|
|
299
|
+
const session = await acceptWithLoginThenVerifyMfa({
|
|
300
|
+
actorId: 702,
|
|
301
|
+
email: "mfa-locale-invitee@example.com",
|
|
302
|
+
password: "mfa-locale-invitee-pw-1234",
|
|
303
|
+
locale: "de-DE",
|
|
304
|
+
});
|
|
305
|
+
expect(session.locale).toBe("de-DE");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("completing the mfa/verify challenge without a stored locale omits the claim", async () => {
|
|
309
|
+
const session = await acceptWithLoginThenVerifyMfa({
|
|
310
|
+
actorId: 703,
|
|
311
|
+
email: "mfa-nolocale-invitee@example.com",
|
|
312
|
+
password: "mfa-nolocale-invitee-pw-1234",
|
|
313
|
+
});
|
|
314
|
+
expect(session.locale).toBeUndefined();
|
|
315
|
+
});
|
|
233
316
|
});
|
|
234
317
|
|
|
235
318
|
describe("invite-accept-with-login: account-status gate", () => {
|
|
@@ -369,6 +369,42 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
|
|
|
369
369
|
// Membership added
|
|
370
370
|
const memberships = await selectMany(stack.db, tenantMembershipsTable, { userId: bobId });
|
|
371
371
|
expect(memberships).toHaveLength(2);
|
|
372
|
+
|
|
373
|
+
// fw#2333 — Bob has no stored locale, so the JWT must carry no locale
|
|
374
|
+
// claim at all (not null, not "").
|
|
375
|
+
const payload = await stack.jwt.verify(body.token as string);
|
|
376
|
+
expect(payload.locale).toBeUndefined();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("Bob accepts with a locale → JWT retains the locale", async () => {
|
|
380
|
+
await asRawClient(stack.db).unsafe(
|
|
381
|
+
`UPDATE "${userTable.tableName}" SET locale = $1 WHERE id = $2`,
|
|
382
|
+
["de-DE", bobId],
|
|
383
|
+
);
|
|
384
|
+
try {
|
|
385
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
386
|
+
|
|
387
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
388
|
+
token,
|
|
389
|
+
email: BOB_EMAIL,
|
|
390
|
+
password: BOB_PASSWORD,
|
|
391
|
+
});
|
|
392
|
+
expect(res.status).toBe(200);
|
|
393
|
+
const body = (await res.json()) as { token?: string };
|
|
394
|
+
expect(body.token).toBeTypeOf("string");
|
|
395
|
+
if (!body.token) throw new Error("invite acceptance did not return a token");
|
|
396
|
+
|
|
397
|
+
const payload = await stack.jwt.verify(body.token);
|
|
398
|
+
expect(payload.locale).toBe("de-DE");
|
|
399
|
+
} finally {
|
|
400
|
+
// Bob is shared across this describe block (other tests rely on his
|
|
401
|
+
// membership state persisting) — restore his locale so later tests
|
|
402
|
+
// don't silently inherit it.
|
|
403
|
+
await asRawClient(stack.db).unsafe(
|
|
404
|
+
`UPDATE "${userTable.tableName}" SET locale = NULL WHERE id = $1`,
|
|
405
|
+
[bobId],
|
|
406
|
+
);
|
|
407
|
+
}
|
|
372
408
|
});
|
|
373
409
|
|
|
374
410
|
test("Bob accepts with a timezone → JWT retains the timezone", async () => {
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
-
import
|
|
10
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
12
|
import {
|
|
12
13
|
createTestUser,
|
|
13
14
|
setupTestStack,
|
|
@@ -18,11 +19,14 @@ import {
|
|
|
18
19
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
20
|
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
20
21
|
import {
|
|
22
|
+
AuthMfaHandlers,
|
|
23
|
+
base32Decode,
|
|
21
24
|
createAuthMfaFeature,
|
|
22
25
|
mfaRequiredConfigHandle,
|
|
23
26
|
mfaStatusCheckerFromFeature,
|
|
24
27
|
} from "../../auth-mfa";
|
|
25
28
|
import { userMfaEntity } from "../../auth-mfa/schema/user-mfa";
|
|
29
|
+
import { currentTotpCode } from "../../auth-mfa/totp";
|
|
26
30
|
import { ConfigHandlers, createConfigFeature } from "../../config";
|
|
27
31
|
import { createConfigResolver } from "../../config/resolver";
|
|
28
32
|
import { configValuesTable } from "../../config/table";
|
|
@@ -42,8 +46,20 @@ let stack: TestStack;
|
|
|
42
46
|
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
43
47
|
const TENANT_ID: TenantId = testTenantId(400);
|
|
44
48
|
|
|
49
|
+
// auth-mfa:write:enable-start-preauth / enable-confirm-preauth both run
|
|
50
|
+
// pre-session (access: { roles: ["all"] }) — dispatched here the same way
|
|
51
|
+
// the framework's /api/auth/mfa/preauth-enable-start and preauth-confirm
|
|
52
|
+
// routes would, with a guest identity. Both handlers derive everything they
|
|
53
|
+
// need from the verified token, not from this actor.
|
|
54
|
+
const GUEST: SessionUser = {
|
|
55
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
56
|
+
tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
|
|
57
|
+
roles: ["all"],
|
|
58
|
+
};
|
|
59
|
+
|
|
45
60
|
beforeAll(async () => {
|
|
46
61
|
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
62
|
+
configureEntityFieldEncryption(encryption);
|
|
47
63
|
const resolver = createConfigResolver({ cipher: encryption });
|
|
48
64
|
const authMfaFeature = createAuthMfaFeature({
|
|
49
65
|
setupTokenSecret: "test-mfa-setup-token-secret-at-least-32-bytes!!",
|
|
@@ -126,4 +142,76 @@ describe("login: mfa-setup-required carries a verifiable preauthSetupToken", ()
|
|
|
126
142
|
expect(verified.payload).toEqual({ userId: created.id, tenantId: TENANT_ID });
|
|
127
143
|
}
|
|
128
144
|
});
|
|
145
|
+
|
|
146
|
+
// fw#2333 — completing the blocked login via auth-mfa:write:enable-start-
|
|
147
|
+
// preauth + enable-confirm-preauth must carry the user's stored locale
|
|
148
|
+
// into the session it mints, same as a plain login.
|
|
149
|
+
async function completeMfaSetupRequiredLogin(opts: {
|
|
150
|
+
actorId: number;
|
|
151
|
+
email: string;
|
|
152
|
+
locale?: string;
|
|
153
|
+
}): Promise<SessionUser> {
|
|
154
|
+
const password = "correct-horse-battery-2026";
|
|
155
|
+
const hash = await hashPassword(password);
|
|
156
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
157
|
+
UserHandlers.create,
|
|
158
|
+
{
|
|
159
|
+
email: opts.email,
|
|
160
|
+
passwordHash: hash,
|
|
161
|
+
displayName: "Unenrolled",
|
|
162
|
+
...(opts.locale !== undefined && { locale: opts.locale }),
|
|
163
|
+
},
|
|
164
|
+
createTestUser({ id: opts.actorId, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
|
|
165
|
+
);
|
|
166
|
+
await seedTenantMembership(stack.db, {
|
|
167
|
+
userId: created.id,
|
|
168
|
+
tenantId: TENANT_ID,
|
|
169
|
+
roles: ["User"],
|
|
170
|
+
});
|
|
171
|
+
await stack.http.writeOk(
|
|
172
|
+
ConfigHandlers.set,
|
|
173
|
+
{ key: mfaRequiredConfigHandle.name, value: "all" },
|
|
174
|
+
createTestUser({ id: opts.actorId + 1, tenantId: TENANT_ID, roles: ["Admin"] }),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
178
|
+
email: opts.email,
|
|
179
|
+
password,
|
|
180
|
+
});
|
|
181
|
+
expect(loginRes.status).toBe(200);
|
|
182
|
+
const loginBody = await loginRes.json();
|
|
183
|
+
expect(typeof loginBody.preauthSetupToken).toBe("string");
|
|
184
|
+
|
|
185
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
186
|
+
AuthMfaHandlers.enableStartPreauth,
|
|
187
|
+
{ preauthSetupToken: loginBody.preauthSetupToken, accountLabel: opts.email },
|
|
188
|
+
GUEST,
|
|
189
|
+
);
|
|
190
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
191
|
+
const secret = base32Decode(secretParam);
|
|
192
|
+
|
|
193
|
+
const confirmed = await stack.http.writeOk<{ session: SessionUser }>(
|
|
194
|
+
AuthMfaHandlers.enableConfirmPreauth,
|
|
195
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
196
|
+
GUEST,
|
|
197
|
+
);
|
|
198
|
+
return confirmed.session;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test("completing mfa-setup-required via enable-start/confirm-preauth carries the user's locale", async () => {
|
|
202
|
+
const session = await completeMfaSetupRequiredLogin({
|
|
203
|
+
actorId: 501,
|
|
204
|
+
email: "preauth-locale@example.com",
|
|
205
|
+
locale: "de-DE",
|
|
206
|
+
});
|
|
207
|
+
expect(session.locale).toBe("de-DE");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("completing mfa-setup-required via enable-start/confirm-preauth without a stored locale omits the claim", async () => {
|
|
211
|
+
const session = await completeMfaSetupRequiredLogin({
|
|
212
|
+
actorId: 503,
|
|
213
|
+
email: "preauth-nolocale@example.com",
|
|
214
|
+
});
|
|
215
|
+
expect(session.locale).toBeUndefined();
|
|
216
|
+
});
|
|
129
217
|
});
|