@cosmicdrift/kumiko-bundled-features 0.211.0 → 0.213.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/package.json +8 -8
  2. package/src/admin-shell/feature.ts +2 -0
  3. package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
  4. package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
  5. package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
  6. package/src/audit/constants.ts +3 -0
  7. package/src/audit/feature.ts +2 -0
  8. package/src/audit/i18n.ts +1 -0
  9. package/src/audit/web/audit-log-detail-screen.tsx +28 -6
  10. package/src/audit/web/audit-log-screen.tsx +51 -23
  11. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -2
  12. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +175 -2
  13. package/src/auth-email-password/feature.ts +9 -4
  14. package/src/auth-email-password/handlers/invite-create.write.ts +14 -4
  15. package/src/auth-email-password/handlers/signup-request.write.ts +48 -34
  16. package/src/auth-email-password/handlers/token-request-handler.ts +20 -4
  17. package/src/auth-email-password/i18n.ts +2 -0
  18. package/src/auth-email-password/magic-link-mail.ts +11 -2
  19. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +15 -6
  20. package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
  21. package/src/auth-email-password/web/auth-gate.tsx +1 -0
  22. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
  23. package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
  24. package/src/auth-email-password/web/session.tsx +1 -0
  25. package/src/auth-email-password/web/signup-complete-screen.tsx +29 -14
  26. package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
  27. package/src/auth-mfa/feature.ts +1 -0
  28. package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
  29. package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
  30. package/src/compliance-profiles/feature.ts +1 -5
  31. package/src/config/db/queries/__tests__/resolver.test.ts +64 -0
  32. package/src/config/db/queries/resolver.ts +5 -3
  33. package/src/crypto-shredding/changes.json +2 -1
  34. package/src/custom-fields/__tests__/feature.test.ts +42 -0
  35. package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
  36. package/src/custom-fields/constants.ts +6 -0
  37. package/src/custom-fields/feature.ts +56 -10
  38. package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
  39. package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
  40. package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
  41. package/src/delivery/feature.ts +1 -3
  42. package/src/feature-toggles/feature.ts +1 -0
  43. package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
  44. package/src/folders/web/folder-manager.tsx +1 -0
  45. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +141 -0
  46. package/src/inbound-mail-foundation/watch-supervisor.ts +152 -11
  47. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
  48. package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
  49. package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
  50. package/src/jobs/feature.ts +25 -1
  51. package/src/jobs/handlers/detail.query.ts +13 -1
  52. package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
  53. package/src/jobs/job-run-logger.ts +68 -18
  54. package/src/jobs/web/job-run-detail-screen.tsx +1 -0
  55. package/src/page-render/web.ts +1 -0
  56. package/src/personal-access-tokens/feature.ts +1 -0
  57. package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
  58. package/src/secrets/constants.ts +12 -0
  59. package/src/secrets/feature.ts +35 -9
  60. package/src/secrets/handlers/delete.write.ts +23 -16
  61. package/src/secrets/handlers/list.query.ts +37 -30
  62. package/src/secrets/handlers/set.write.ts +43 -36
  63. package/src/secrets/index.ts +5 -0
  64. package/src/seo/feature.ts +1 -0
  65. package/src/tags/feature.ts +1 -0
  66. package/src/tags/web/tag-chip.tsx +2 -0
  67. package/src/tags/web/tag-manager.tsx +2 -1
  68. package/src/tags/web/tag-picker.tsx +1 -0
  69. package/src/template-resolver/web/client-plugin.tsx +2 -0
  70. package/src/tier-engine/feature.ts +2 -0
  71. package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
  72. package/src/user-data-rights/feature.ts +2 -0
  73. package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  74. package/src/user-profile/i18n.ts +1 -0
  75. package/src/user-profile/web/profile-screen.tsx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.211.0",
3
+ "version": "0.213.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.211.0",
130
- "@cosmicdrift/kumiko-framework": "0.211.0",
131
- "@cosmicdrift/kumiko-headless": "0.211.0",
132
- "@cosmicdrift/kumiko-renderer": "0.211.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.211.0",
134
- "@cosmicdrift/kumiko-types": "0.211.0",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.213.0",
130
+ "@cosmicdrift/kumiko-framework": "0.213.0",
131
+ "@cosmicdrift/kumiko-headless": "0.213.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.213.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.213.0",
134
+ "@cosmicdrift/kumiko-types": "0.213.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,6 +160,6 @@
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.211.0"
163
+ "@cosmicdrift/kumiko-locale-de": "0.213.0"
164
164
  }
165
165
  }
@@ -63,6 +63,7 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
63
63
  ...(includeTierAdmin ? (["admin-shell:nav:tier-admin"] as const) : []),
64
64
  ];
65
65
 
66
+ // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
66
67
  r.screen({
67
68
  id: TENANT_OVERVIEW_SCREEN_ID,
68
69
  type: "custom",
@@ -77,6 +78,7 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
77
78
  order: 1,
78
79
  });
79
80
 
81
+ // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
80
82
  r.screen({
81
83
  id: PLATFORM_OVERVIEW_SCREEN_ID,
82
84
  type: "custom",
@@ -13,6 +13,7 @@ export function PlatformOverviewScreen(): ReactNode {
13
13
  const dispatcher = useDispatcher();
14
14
  const [state, setState] = useState<OverviewState>({ kind: "loading" });
15
15
 
16
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
16
17
  useEffect(() => {
17
18
  let cancelled = false;
18
19
  void (async (): Promise<void> => {
@@ -12,6 +12,7 @@ export function TenantOverviewScreen(): ReactNode {
12
12
  const dispatcher = useDispatcher();
13
13
  const [state, setState] = useState<OverviewState>({ kind: "loading" });
14
14
 
15
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
15
16
  useEffect(() => {
16
17
  let cancelled = false;
17
18
  void (async (): Promise<void> => {
@@ -0,0 +1,228 @@
1
+ // @runtime client
2
+ // DOM test for AuditLogScreen actor-name resolution (#audit-log-actor-name).
3
+ // Provider wrapper is local — renderer-web → bundled-features forbids importing
4
+ // renderer-web test-utils from here.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
8
+ import {
9
+ createStaticLocaleResolver,
10
+ DispatcherProvider,
11
+ kumikoDefaultTranslations,
12
+ type LiveEventSubscriber,
13
+ LiveEventsProvider,
14
+ LocaleProvider,
15
+ type NavApi,
16
+ NavProvider,
17
+ PrimitivesProvider,
18
+ TokensProvider,
19
+ } from "@cosmicdrift/kumiko-renderer";
20
+ import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-web";
21
+ import { render, waitFor } from "@testing-library/react";
22
+ import type { ReactNode } from "react";
23
+ import { TenantQueries } from "../../tenant/constants";
24
+ import { AuditQueries } from "../constants";
25
+ import { AuditLogScreen } from "../web/audit-log-screen";
26
+ import { defaultTranslations } from "../web/i18n";
27
+
28
+ if (typeof globalThis.ResizeObserver === "undefined") {
29
+ globalThis.ResizeObserver = class {
30
+ observe(): void {}
31
+ unobserve(): void {}
32
+ disconnect(): void {}
33
+ } as unknown as typeof ResizeObserver;
34
+ }
35
+
36
+ const stubLiveEvents: LiveEventSubscriber = () => () => {};
37
+ const stubTokens = {
38
+ tokens: defaultTokens,
39
+ mode: "light" as const,
40
+ setMode: () => {},
41
+ toggleMode: () => {},
42
+ };
43
+ const stubResolver = createStaticLocaleResolver();
44
+ const stubNav: NavApi = {
45
+ route: undefined,
46
+ navigate: () => {},
47
+ replace: () => {},
48
+ hrefFor: (target) => {
49
+ if (!("screenId" in target)) return "";
50
+ return target.entityId !== undefined
51
+ ? `/${target.screenId}/${target.entityId}`
52
+ : `/${target.screenId}`;
53
+ },
54
+ searchParams: {},
55
+ setSearchParams: () => {},
56
+ };
57
+
58
+ type AuditRowFixture = {
59
+ readonly id: string;
60
+ readonly aggregateId: string;
61
+ readonly aggregateType: string;
62
+ readonly type: string;
63
+ readonly createdBy: string;
64
+ readonly createdAt: string;
65
+ readonly payload: Record<string, unknown>;
66
+ };
67
+
68
+ type MemberRowFixture = {
69
+ readonly userId: string;
70
+ readonly email: string | null;
71
+ readonly displayName: string | null;
72
+ };
73
+
74
+ const AUDIT_ROWS: readonly AuditRowFixture[] = [
75
+ {
76
+ id: "1",
77
+ aggregateId: "agg-1",
78
+ aggregateType: "widget",
79
+ type: "widget.created",
80
+ createdBy: "user-1",
81
+ createdAt: "2026-08-01T00:00:00.000Z",
82
+ payload: {},
83
+ },
84
+ {
85
+ id: "2",
86
+ aggregateId: "agg-2",
87
+ aggregateType: "widget",
88
+ type: "widget.updated",
89
+ createdBy: "user-2",
90
+ createdAt: "2026-08-02T00:00:00.000Z",
91
+ payload: {},
92
+ },
93
+ {
94
+ id: "3",
95
+ aggregateId: "agg-3",
96
+ aggregateType: "widget",
97
+ type: "widget.deleted",
98
+ createdBy: "user-missing",
99
+ createdAt: "2026-08-03T00:00:00.000Z",
100
+ payload: {},
101
+ },
102
+ {
103
+ id: "4",
104
+ aggregateId: "agg-4",
105
+ aggregateType: "widget",
106
+ type: "widget.synced",
107
+ createdBy: "system",
108
+ createdAt: "2026-08-04T00:00:00.000Z",
109
+ payload: {},
110
+ },
111
+ {
112
+ id: "5",
113
+ aggregateId: "agg-5",
114
+ aggregateType: "widget",
115
+ type: "widget.archived",
116
+ createdBy: "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
117
+ createdAt: "2026-08-05T00:00:00.000Z",
118
+ payload: {},
119
+ },
120
+ ];
121
+
122
+ const MEMBERS: readonly MemberRowFixture[] = [
123
+ { userId: "user-1", email: "alice@example.com", displayName: "Alice Example" },
124
+ { userId: "user-2", email: "bob@example.com", displayName: null },
125
+ ];
126
+
127
+ function makeDispatcher(): Dispatcher {
128
+ const statusStore = createStore<DispatcherStatus>("online");
129
+ const query = (async (type: string) => {
130
+ if (type === AuditQueries.list) {
131
+ return { isSuccess: true, data: { rows: AUDIT_ROWS, nextBefore: null } };
132
+ }
133
+ if (type === TenantQueries.members) {
134
+ return { isSuccess: true, data: MEMBERS };
135
+ }
136
+ return { isSuccess: true, data: null };
137
+ }) as unknown as Dispatcher["query"];
138
+ return {
139
+ write: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["write"],
140
+ query,
141
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
142
+ statusStore,
143
+ async *stream() {},
144
+ pendingWrites: () => [],
145
+ pendingFiles: () => [],
146
+ } as unknown as Dispatcher; // @cast-boundary test-stub
147
+ }
148
+
149
+ function renderScreen(): ReturnType<typeof render> {
150
+ const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
151
+ <TokensProvider value={stubTokens}>
152
+ <LocaleProvider
153
+ resolver={stubResolver}
154
+ fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
155
+ >
156
+ <PrimitivesProvider value={defaultPrimitives}>
157
+ <NavProvider value={stubNav}>
158
+ <LiveEventsProvider value={stubLiveEvents}>
159
+ <DispatcherProvider dispatcher={makeDispatcher()}>{children}</DispatcherProvider>
160
+ </LiveEventsProvider>
161
+ </NavProvider>
162
+ </PrimitivesProvider>
163
+ </LocaleProvider>
164
+ </TokensProvider>
165
+ );
166
+ return render(<AuditLogScreen />, { wrapper });
167
+ }
168
+
169
+ describe("AuditLogScreen — actor name resolution", () => {
170
+ test("known member with displayName → cell shows the display name", async () => {
171
+ const view = renderScreen();
172
+ await waitFor(() => {
173
+ const cell = view.queryByTestId("cell-1-actor");
174
+ if (cell === null) throw new Error("actor cell not rendered yet");
175
+ });
176
+ expect(view.getByTestId("cell-1-actor").textContent).toBe("Alice Example");
177
+ });
178
+
179
+ test("known member without displayName → cell falls back to email", async () => {
180
+ const view = renderScreen();
181
+ await waitFor(() => {
182
+ const cell = view.queryByTestId("cell-2-actor");
183
+ if (cell === null) throw new Error("actor cell not rendered yet");
184
+ });
185
+ expect(view.getByTestId("cell-2-actor").textContent).toBe("bob@example.com");
186
+ });
187
+
188
+ test("createdBy not in members list → cell is empty, never the raw id", async () => {
189
+ const view = renderScreen();
190
+ await waitFor(() => {
191
+ const cell = view.queryByTestId("cell-3-actor");
192
+ if (cell === null) throw new Error("actor cell not rendered yet");
193
+ });
194
+ const cell = view.getByTestId("cell-3-actor");
195
+ expect(cell.textContent).toBe("");
196
+ expect(cell.textContent).not.toBe("user-missing");
197
+ });
198
+
199
+ test("table has no aggregate column", async () => {
200
+ const view = renderScreen();
201
+ await waitFor(() => {
202
+ const cell = view.queryByTestId("cell-1-actor");
203
+ if (cell === null) throw new Error("table not rendered yet");
204
+ });
205
+ expect(view.queryByTestId("cell-1-aggregate")).toBeNull();
206
+ expect(view.container.textContent).not.toContain("agg-1");
207
+ });
208
+
209
+ test("createdBy is the system literal → cell shows the System label", async () => {
210
+ const view = renderScreen();
211
+ await waitFor(() => {
212
+ const cell = view.queryByTestId("cell-4-actor");
213
+ if (cell === null) throw new Error("actor cell not rendered yet");
214
+ });
215
+ expect(view.getByTestId("cell-4-actor").textContent).toBe("System");
216
+ });
217
+
218
+ test("createdBy is an unresolvable UUID → cell stays empty, raw UUID never rendered", async () => {
219
+ const view = renderScreen();
220
+ await waitFor(() => {
221
+ const cell = view.queryByTestId("cell-5-actor");
222
+ if (cell === null) throw new Error("actor cell not rendered yet");
223
+ });
224
+ const cell = view.getByTestId("cell-5-actor");
225
+ expect(cell.textContent).toBe("");
226
+ expect(view.container.textContent).not.toContain("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
227
+ });
228
+ });
@@ -2,6 +2,9 @@
2
2
  // Qualified handler names + screen ids — shared by server feature and web client.
3
3
  export const AUDIT_FEATURE = "audit" as const;
4
4
 
5
+ /** Literal `createdBy` written by system-authored events (e.g. delivery attempts). */
6
+ export const SYSTEM_ACTOR_ID = "system" as const;
7
+
5
8
  export const AuditQueries = {
6
9
  list: "audit:query:list",
7
10
  details: "audit:query:details",
@@ -37,12 +37,14 @@ export function createAuditFeature(): FeatureDefinition {
37
37
  details: r.queryHandler(detailsQuery),
38
38
  };
39
39
 
40
+ // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
40
41
  r.screen({
41
42
  id: AUDIT_LOG_SCREEN_ID,
42
43
  type: "custom",
43
44
  renderer: { react: { __component: "AuditLogScreen" } },
44
45
  access: { roles: access.admin },
45
46
  });
47
+ // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
46
48
  r.screen({
47
49
  id: AUDIT_LOG_DETAIL_SCREEN_ID,
48
50
  type: "custom",
package/src/audit/i18n.ts CHANGED
@@ -16,6 +16,7 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
16
16
  "audit.log.col.type": { en: "Event" },
17
17
  "audit.log.col.aggregate": { en: "Aggregate" },
18
18
  "audit.log.col.actor": { en: "Actor" },
19
+ "audit.log.actor.system": { en: "System" },
19
20
  "audit.log.filter.eventType": { en: "Event type" },
20
21
  "audit.log.filter.aggregateType": { en: "Aggregate type" },
21
22
  "audit.log.filter.from": { en: "From" },
@@ -10,7 +10,8 @@ import {
10
10
  } from "@cosmicdrift/kumiko-renderer";
11
11
  import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
12
12
  import { type ReactNode, useCallback, useEffect, useState } from "react";
13
- import { AuditQueries } from "../constants";
13
+ import { TenantQueries } from "../../tenant/constants";
14
+ import { AuditQueries, SYSTEM_ACTOR_ID } from "../constants";
14
15
 
15
16
  type AuditDetail = {
16
17
  readonly id: string;
@@ -23,11 +24,17 @@ type AuditDetail = {
23
24
  readonly metadata: Record<string, unknown>;
24
25
  };
25
26
 
27
+ type MemberRow = {
28
+ readonly userId: string;
29
+ readonly email: string | null;
30
+ readonly displayName: string | null;
31
+ };
32
+
26
33
  type State =
27
34
  | { readonly kind: "loading" }
28
35
  | { readonly kind: "missing" }
29
36
  | { readonly kind: "error"; readonly message: string }
30
- | { readonly kind: "ready"; readonly event: AuditDetail };
37
+ | { readonly kind: "ready"; readonly event: AuditDetail; readonly actorName: string };
31
38
 
32
39
  export function AuditLogDetailScreen(): ReactNode {
33
40
  const t = useTranslation();
@@ -52,9 +59,24 @@ export function AuditLogDetailScreen(): ReactNode {
52
59
  setState({ kind: "missing" });
53
60
  return;
54
61
  }
55
- setState({ kind: "ready", event: res.data });
56
- }, [dispatcher, eventId]);
62
+ const event = res.data;
63
+ const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {});
64
+ const member = membersRes.isSuccess
65
+ ? membersRes.data.find((m) => m.userId === event.createdBy)
66
+ : undefined;
67
+ setState({
68
+ kind: "ready",
69
+ event,
70
+ actorName:
71
+ event.createdBy === SYSTEM_ACTOR_ID
72
+ ? t("audit.log.actor.system")
73
+ : member
74
+ ? (member.displayName ?? member.email ?? "")
75
+ : "",
76
+ });
77
+ }, [dispatcher, eventId, t]);
57
78
 
79
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
58
80
  useEffect(() => {
59
81
  void load();
60
82
  }, [load]);
@@ -83,7 +105,7 @@ export function AuditLogDetailScreen(): ReactNode {
83
105
  );
84
106
  }
85
107
 
86
- const { event } = state;
108
+ const { event, actorName } = state;
87
109
 
88
110
  return (
89
111
  <FormScreenShell testId="audit-log-detail-screen" className="flex flex-col gap-6">
@@ -101,7 +123,7 @@ export function AuditLogDetailScreen(): ReactNode {
101
123
  </div>
102
124
  <div>
103
125
  <dt className="font-medium">{t("audit.log.col.actor")}</dt>
104
- <dd data-testid="audit-detail-actor">{event.createdBy}</dd>
126
+ <dd data-testid="audit-detail-actor">{actorName}</dd>
105
127
  </div>
106
128
  <div>
107
129
  <dt className="font-medium">{t("audit.log.detail.field.id")}</dt>
@@ -11,8 +11,10 @@ import {
11
11
  usePrimitives,
12
12
  useTranslation,
13
13
  } from "@cosmicdrift/kumiko-renderer";
14
+ import { Filter, RotateCcw } from "lucide-react";
14
15
  import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
15
- import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries } from "../constants";
16
+ import { TenantQueries } from "../../tenant/constants";
17
+ import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries, SYSTEM_ACTOR_ID } from "../constants";
16
18
 
17
19
  type AuditRow = {
18
20
  readonly id: string;
@@ -26,6 +28,12 @@ type AuditRow = {
26
28
 
27
29
  type AuditResponse = { readonly rows: readonly AuditRow[]; readonly nextBefore: string | null };
28
30
 
31
+ type MemberRow = {
32
+ readonly userId: string;
33
+ readonly email: string | null;
34
+ readonly displayName: string | null;
35
+ };
36
+
29
37
  type Filters = {
30
38
  readonly eventType: string;
31
39
  readonly aggregateType: string;
@@ -40,6 +48,7 @@ type State =
40
48
  readonly kind: "ready";
41
49
  readonly rows: readonly AuditRow[];
42
50
  readonly nextBefore: string | null;
51
+ readonly names: ReadonlyMap<string, string>;
43
52
  };
44
53
 
45
54
  const EMPTY_FILTERS: Filters = { eventType: "", aggregateType: "", from: "", to: "" };
@@ -57,29 +66,44 @@ export function AuditLogScreen(): ReactNode {
57
66
  const filtersRef = useRef(filters);
58
67
  filtersRef.current = filters;
59
68
 
69
+ // Loaded once per screen lifetime, not per page — members rarely change
70
+ // mid-session and refetching on every "older" click is wasted work.
71
+ const membersRef = useRef<Promise<ReadonlyMap<string, string>> | null>(null);
72
+
60
73
  const load = useCallback(
61
74
  async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
62
75
  setState({ kind: "loading" });
63
76
  const f = overrideFilters ?? filtersRef.current;
64
- const res = await dispatcher.query<AuditResponse>(AuditQueries.list, {
65
- limit: 50,
66
- ...(cursor !== undefined && { before: cursor }),
67
- ...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
68
- ...(f.aggregateType.trim() !== "" && {
69
- aggregateType: f.aggregateType.trim(),
77
+ membersRef.current ??= dispatcher
78
+ .query<readonly MemberRow[]>(TenantQueries.members, {})
79
+ .then((res) =>
80
+ res.isSuccess
81
+ ? new Map(res.data.map((m) => [m.userId, m.displayName ?? m.email ?? ""] as const))
82
+ : new Map<string, string>(),
83
+ );
84
+ const [res, names] = await Promise.all([
85
+ dispatcher.query<AuditResponse>(AuditQueries.list, {
86
+ limit: 50,
87
+ ...(cursor !== undefined && { before: cursor }),
88
+ ...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
89
+ ...(f.aggregateType.trim() !== "" && {
90
+ aggregateType: f.aggregateType.trim(),
91
+ }),
92
+ ...(f.from !== "" && { from: toIsoStart(f.from) }),
93
+ ...(f.to !== "" && { to: toIsoEnd(f.to) }),
70
94
  }),
71
- ...(f.from !== "" && { from: toIsoStart(f.from) }),
72
- ...(f.to !== "" && { to: toIsoEnd(f.to) }),
73
- });
95
+ membersRef.current,
96
+ ]);
74
97
  if (!res.isSuccess) {
75
98
  setState({ kind: "error", message: res.error.message });
76
99
  return;
77
100
  }
78
- setState({ kind: "ready", rows: res.data.rows, nextBefore: res.data.nextBefore });
101
+ setState({ kind: "ready", rows: res.data.rows, nextBefore: res.data.nextBefore, names });
79
102
  },
80
103
  [dispatcher],
81
104
  );
82
105
 
106
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
83
107
  useEffect(() => {
84
108
  void load(before);
85
109
  }, [load, before]);
@@ -105,8 +129,8 @@ export function AuditLogScreen(): ReactNode {
105
129
 
106
130
  return (
107
131
  <div className="w-full" data-testid="audit-log-screen">
108
- <div className="flex flex-col gap-4 px-6 pt-6">
109
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
132
+ <div className="flex flex-wrap items-end gap-4 px-6 pt-6">
133
+ <div className="min-w-40 flex-1">
110
134
  <Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
111
135
  <Input
112
136
  kind="text"
@@ -116,6 +140,8 @@ export function AuditLogScreen(): ReactNode {
116
140
  onChange={(v: string) => setFilters((f) => ({ ...f, eventType: v }))}
117
141
  />
118
142
  </Field>
143
+ </div>
144
+ <div className="min-w-40 flex-1">
119
145
  <Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
120
146
  <Input
121
147
  kind="text"
@@ -125,6 +151,8 @@ export function AuditLogScreen(): ReactNode {
125
151
  onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
126
152
  />
127
153
  </Field>
154
+ </div>
155
+ <div className="min-w-40 flex-1">
128
156
  <Field id="audit-filter-from" label={t("audit.log.filter.from")}>
129
157
  <Input
130
158
  kind="date"
@@ -134,6 +162,8 @@ export function AuditLogScreen(): ReactNode {
134
162
  onChange={(v) => setFilters((f) => ({ ...f, from: v ?? "" }))}
135
163
  />
136
164
  </Field>
165
+ </div>
166
+ <div className="min-w-40 flex-1">
137
167
  <Field id="audit-filter-to" label={t("audit.log.filter.to")}>
138
168
  <Input
139
169
  kind="date"
@@ -154,7 +184,8 @@ export function AuditLogScreen(): ReactNode {
154
184
  }}
155
185
  testId="audit-log-apply-filters"
156
186
  >
157
- {t("audit.log.filter.apply")}
187
+ <Filter className="h-4 w-4" />
188
+ <span>{t("audit.log.filter.apply")}</span>
158
189
  </Button>
159
190
  <Button
160
191
  type="button"
@@ -166,7 +197,8 @@ export function AuditLogScreen(): ReactNode {
166
197
  }}
167
198
  testId="audit-log-reset-filters"
168
199
  >
169
- {t("audit.log.filter.reset")}
200
+ <RotateCcw className="h-4 w-4" />
201
+ <span>{t("audit.log.filter.reset")}</span>
170
202
  </Button>
171
203
  </div>
172
204
  </div>
@@ -176,12 +208,6 @@ export function AuditLogScreen(): ReactNode {
176
208
  columns={[
177
209
  { field: "when", label: t("audit.log.col.when"), type: "string", sortable: true },
178
210
  { field: "type", label: t("audit.log.col.type"), type: "string", sortable: true },
179
- {
180
- field: "aggregate",
181
- label: t("audit.log.col.aggregate"),
182
- type: "string",
183
- sortable: false,
184
- },
185
211
  { field: "actor", label: t("audit.log.col.actor"), type: "string", sortable: false },
186
212
  ]}
187
213
  sort={sort}
@@ -191,8 +217,10 @@ export function AuditLogScreen(): ReactNode {
191
217
  values: {
192
218
  when: formatWhen(row.createdAt),
193
219
  type: row.type,
194
- aggregate: `${row.aggregateType} / ${row.aggregateId}`,
195
- actor: row.createdBy,
220
+ actor:
221
+ row.createdBy === SYSTEM_ACTOR_ID
222
+ ? t("audit.log.actor.system")
223
+ : (state.names.get(row.createdBy) ?? ""),
196
224
  },
197
225
  }))}
198
226
  onRowClick={(row) => openDetail(row.id)}
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
4
4
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
5
5
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
6
7
  import {
7
8
  setupTestStack,
8
9
  type TestStack,
@@ -11,6 +12,7 @@ import {
11
12
  unsafePushTables,
12
13
  } from "@cosmicdrift/kumiko-framework/stack";
13
14
  import { createTestEnvelopeCipher, seedRow } from "@cosmicdrift/kumiko-framework/testing";
15
+ import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
14
16
  import { Temporal } from "temporal-polyfill";
15
17
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
16
18
  import { createConfigFeature } from "../../config";
@@ -39,6 +41,13 @@ import { signResetToken } from "../reset-token";
39
41
  // directly (no jobRunner in the test stack → inline send).
40
42
  const emailTransport = createInMemoryTransport();
41
43
 
44
+ // Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand — that
45
+ // constant is a framework-internal implementation detail, not exported from
46
+ // the public /api barrel (same as TENANT_HEADER_NAME).
47
+ const LOCALE_HEADER_NAME = "X-Locale";
48
+
49
+ registerMailTranslations("de", localeDeBundle);
50
+
42
51
  // Records the userId every time the sessions feature's auto-revoke hook
43
52
  // fires after a password change. The session-revoke tests assert on this
44
53
  // list — we don't need a full session store, just proof the hook fired.
@@ -146,8 +155,12 @@ async function seedUser(opts: {
146
155
  return { id: created.id, tenantId };
147
156
  }
148
157
 
149
- async function post(path: string, body: unknown): Promise<Response> {
150
- return stack.http.raw("POST", path, body);
158
+ async function post(
159
+ path: string,
160
+ body: unknown,
161
+ headers?: Record<string, string>,
162
+ ): Promise<Response> {
163
+ return stack.http.raw("POST", path, body, headers);
151
164
  }
152
165
 
153
166
  // --- request-password-reset -----------------------------------------------
@@ -182,6 +195,22 @@ describe("POST /auth/request-password-reset", () => {
182
195
  expect(await res.json()).toEqual({ isSuccess: true });
183
196
  expect(emailTransport.sent).toHaveLength(0);
184
197
  });
198
+
199
+ test("X-Locale: de → the reset mail is rendered in German, same as signup", async () => {
200
+ await seedUser({ email: "locale-de@example.com", password: "initial-pw!" });
201
+
202
+ const res = await post(
203
+ "/api/auth/request-password-reset",
204
+ { email: "locale-de@example.com" },
205
+ { [LOCALE_HEADER_NAME]: "de" },
206
+ );
207
+
208
+ expect(res.status).toBe(200);
209
+ expect(emailTransport.sent).toHaveLength(1);
210
+ const sent = emailTransport.sent[0];
211
+ if (!sent) throw new Error("no email sent");
212
+ expect(sent.subject).toContain("Passwort zurücksetzen");
213
+ });
185
214
  });
186
215
 
187
216
  // --- reset-password --------------------------------------------------------