@cosmicdrift/kumiko-bundled-features 0.239.0 → 0.241.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 (68) hide show
  1. package/package.json +11 -11
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
  3. package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
  4. package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
  5. package/src/admin-shell/changes.json +6 -0
  6. package/src/admin-shell/feature.ts +60 -6
  7. package/src/admin-shell/i18n.ts +0 -7
  8. package/src/admin-shell/web/client-plugin.tsx +1 -11
  9. package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
  10. package/src/audit/changes.json +7 -0
  11. package/src/audit/feature.ts +57 -7
  12. package/src/audit/handlers/list.query.ts +50 -14
  13. package/src/audit/i18n.ts +3 -18
  14. package/src/audit/web/client-plugin.tsx +1 -7
  15. package/src/audit/web/index.ts +0 -2
  16. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -5
  17. package/src/config/__tests__/config.integration.test.ts +31 -0
  18. package/src/config/handlers/readiness.query.ts +8 -1
  19. package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
  20. package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
  21. package/src/jobs/changes.json +7 -0
  22. package/src/jobs/feature.ts +101 -9
  23. package/src/jobs/handlers/list.query.ts +21 -4
  24. package/src/jobs/handlers/trigger.write.ts +16 -3
  25. package/src/jobs/i18n.ts +3 -25
  26. package/src/jobs/index.ts +1 -0
  27. package/src/jobs/web/client-plugin.tsx +1 -7
  28. package/src/jobs/web/index.ts +0 -2
  29. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +1 -1
  30. package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
  31. package/src/tenant/handlers/list.query.ts +1 -0
  32. package/src/tier-engine/__tests__/drift.test.ts +1 -1
  33. package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
  34. package/src/tier-engine/changes.json +9 -1
  35. package/src/tier-engine/constants.ts +5 -5
  36. package/src/tier-engine/feature.ts +36 -10
  37. package/src/tier-engine/i18n.ts +19 -22
  38. package/src/tier-engine/web/client-plugin.tsx +5 -11
  39. package/src/tier-engine/web/index.ts +0 -1
  40. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
  41. package/src/user-data-rights/changes.json +7 -0
  42. package/src/user-data-rights/constants.ts +20 -6
  43. package/src/user-data-rights/feature.ts +113 -14
  44. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
  45. package/src/user-data-rights/web/client-plugin.tsx +19 -21
  46. package/src/user-data-rights/web/i18n.ts +6 -11
  47. package/src/user-data-rights/web/index.ts +1 -1
  48. package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
  49. package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
  50. package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
  51. package/src/user-profile/changes.json +7 -0
  52. package/src/user-profile/constants.ts +15 -7
  53. package/src/user-profile/feature.ts +96 -9
  54. package/src/user-profile/i18n.ts +8 -12
  55. package/src/user-profile/web/client-plugin.ts +22 -5
  56. package/src/user-profile/web/index.ts +1 -1
  57. package/src/user-profile/web/profile-screen.tsx +122 -265
  58. package/src/admin-shell/web/overview-layout.tsx +0 -69
  59. package/src/admin-shell/web/overview-query.ts +0 -15
  60. package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
  61. package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
  62. package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
  63. package/src/audit/web/audit-log-detail-screen.tsx +0 -173
  64. package/src/audit/web/audit-log-screen.tsx +0 -290
  65. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
  66. package/src/jobs/web/job-run-detail-screen.tsx +0 -181
  67. package/src/jobs/web/job-runs-screen.tsx +0 -290
  68. package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
@@ -1,91 +0,0 @@
1
- // @runtime client
2
-
3
- import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
- import { type ReactNode, useEffect, useState } from "react";
5
- import { JobQueries } from "../../jobs/constants";
6
- import { TenantQueries } from "../../tenant/constants";
7
- import { UserQueries } from "../../user/constants";
8
- import { OverviewLayout, type OverviewState } from "./overview-layout";
9
- import { overviewQuery } from "./overview-query";
10
-
11
- export function PlatformOverviewScreen(): ReactNode {
12
- const t = useTranslation();
13
- const dispatcher = useDispatcher();
14
- const [state, setState] = useState<OverviewState>({ kind: "loading" });
15
-
16
- // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
17
- useEffect(() => {
18
- let cancelled = false;
19
- void (async (): Promise<void> => {
20
- const tenantsRes = await overviewQuery<{ readonly total: number }>(
21
- "platform",
22
- dispatcher,
23
- TenantQueries.list,
24
- { totalCount: true },
25
- );
26
- if (cancelled) return;
27
- if (!tenantsRes.isSuccess) {
28
- setState({ kind: "error", message: tenantsRes.error.message });
29
- return;
30
- }
31
-
32
- const usersRes = await overviewQuery<{ readonly total: number }>(
33
- "platform",
34
- dispatcher,
35
- UserQueries.list,
36
- { totalCount: true },
37
- );
38
- if (cancelled) return;
39
- if (!usersRes.isSuccess) {
40
- setState({ kind: "error", message: usersRes.error.message });
41
- return;
42
- }
43
-
44
- const failedJobsRes = await overviewQuery<{ readonly total: number }>(
45
- "platform",
46
- dispatcher,
47
- JobQueries.list,
48
- { status: "failed", totalCount: true },
49
- );
50
- if (cancelled) return;
51
- if (!failedJobsRes.isSuccess) {
52
- setState({ kind: "error", message: failedJobsRes.error.message });
53
- return;
54
- }
55
-
56
- setState({
57
- kind: "ready",
58
- cards: [
59
- {
60
- label: t("admin-shell:overview.tenants"),
61
- value: String(tenantsRes.data.total),
62
- },
63
- {
64
- label: t("admin-shell:overview.users"),
65
- value: String(usersRes.data.total),
66
- },
67
- {
68
- label: t("admin-shell:overview.failedJobs"),
69
- value: String(failedJobsRes.data.total),
70
- hint:
71
- failedJobsRes.data.total > 0 ? t("admin-shell:overview.failedJobsHint") : undefined,
72
- attention: failedJobsRes.data.total > 0,
73
- },
74
- ],
75
- });
76
- })();
77
- return () => {
78
- cancelled = true;
79
- };
80
- }, [dispatcher, t]);
81
-
82
- return (
83
- <OverviewLayout
84
- testId="platform-overview-screen"
85
- title={t("admin-shell:overview.platformTitle")}
86
- state={state}
87
- loadingLabel={t("admin-shell:overview.loading")}
88
- columns={3}
89
- />
90
- );
91
- }
@@ -1,91 +0,0 @@
1
- // @runtime client
2
-
3
- import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
- import { type ReactNode, useEffect, useState } from "react";
5
- import { ConfigQueries } from "../../config/constants";
6
- import { TenantQueries } from "../../tenant/constants";
7
- import { OverviewLayout, type OverviewState } from "./overview-layout";
8
- import { overviewQuery } from "./overview-query";
9
-
10
- export function TenantOverviewScreen(): ReactNode {
11
- const t = useTranslation();
12
- const dispatcher = useDispatcher();
13
- const [state, setState] = useState<OverviewState>({ kind: "loading" });
14
-
15
- // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
16
- useEffect(() => {
17
- let cancelled = false;
18
- void (async (): Promise<void> => {
19
- const invitationsRes = await overviewQuery<readonly unknown[]>(
20
- "tenant",
21
- dispatcher,
22
- TenantQueries.invitations,
23
- {},
24
- );
25
- if (cancelled) return;
26
- if (!invitationsRes.isSuccess) {
27
- setState({ kind: "error", message: invitationsRes.error.message });
28
- return;
29
- }
30
-
31
- const membersRes = await overviewQuery<readonly unknown[]>(
32
- "tenant",
33
- dispatcher,
34
- TenantQueries.members,
35
- {},
36
- );
37
- if (cancelled) return;
38
- if (!membersRes.isSuccess) {
39
- setState({ kind: "error", message: membersRes.error.message });
40
- return;
41
- }
42
-
43
- const readinessRes = await overviewQuery<{ readonly missing: readonly unknown[] }>(
44
- "tenant",
45
- dispatcher,
46
- ConfigQueries.readiness,
47
- {},
48
- );
49
- if (cancelled) return;
50
- if (!readinessRes.isSuccess) {
51
- setState({ kind: "error", message: readinessRes.error.message });
52
- return;
53
- }
54
-
55
- setState({
56
- kind: "ready",
57
- cards: [
58
- {
59
- label: t("admin-shell:overview.pendingInvitations"),
60
- value: String(invitationsRes.data.length),
61
- },
62
- {
63
- label: t("admin-shell:overview.members"),
64
- value: String(membersRes.data.length),
65
- },
66
- {
67
- label: t("admin-shell:overview.missingConfig"),
68
- value: String(readinessRes.data.missing.length),
69
- hint:
70
- readinessRes.data.missing.length > 0
71
- ? t("admin-shell:overview.missingConfigHint")
72
- : undefined,
73
- attention: readinessRes.data.missing.length > 0,
74
- },
75
- ],
76
- });
77
- })();
78
- return () => {
79
- cancelled = true;
80
- };
81
- }, [dispatcher, t]);
82
-
83
- return (
84
- <OverviewLayout
85
- testId="tenant-overview-screen"
86
- title={t("admin-shell:overview.tenantTitle")}
87
- state={state}
88
- loadingLabel={t("admin-shell:overview.loading")}
89
- />
90
- );
91
- }
@@ -1,302 +0,0 @@
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 { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries } from "../constants";
25
- import { AuditLogDetailScreen } from "../web/audit-log-detail-screen";
26
- import { AuditLogScreen } from "../web/audit-log-screen";
27
- import { defaultTranslations } from "../web/i18n";
28
-
29
- if (typeof globalThis.ResizeObserver === "undefined") {
30
- globalThis.ResizeObserver = class {
31
- observe(): void {}
32
- unobserve(): void {}
33
- disconnect(): void {}
34
- } as unknown as typeof ResizeObserver;
35
- }
36
-
37
- const stubLiveEvents: LiveEventSubscriber = () => () => {};
38
- const stubTokens = {
39
- tokens: defaultTokens,
40
- mode: "light" as const,
41
- setMode: () => {},
42
- toggleMode: () => {},
43
- };
44
- const stubResolver = createStaticLocaleResolver();
45
- const stubNav: NavApi = {
46
- route: undefined,
47
- navigate: () => {},
48
- replace: () => {},
49
- hrefFor: (target) => {
50
- if (!("screenId" in target)) return "";
51
- return target.entityId !== undefined
52
- ? `/${target.screenId}/${target.entityId}`
53
- : `/${target.screenId}`;
54
- },
55
- searchParams: {},
56
- setSearchParams: () => {},
57
- };
58
-
59
- type AuditRowFixture = {
60
- readonly id: string;
61
- readonly aggregateId: string;
62
- readonly aggregateType: string;
63
- readonly type: string;
64
- readonly createdBy: string;
65
- readonly createdAt: string;
66
- readonly payload: Record<string, unknown>;
67
- };
68
-
69
- type MemberRowFixture = {
70
- readonly userId: string;
71
- readonly email: string | null;
72
- readonly displayName: string | null;
73
- };
74
-
75
- const AUDIT_ROWS: readonly AuditRowFixture[] = [
76
- {
77
- id: "1",
78
- aggregateId: "agg-1",
79
- aggregateType: "widget",
80
- type: "widget.created",
81
- createdBy: "user-1",
82
- createdAt: "2026-08-01T00:00:00.000Z",
83
- payload: {},
84
- },
85
- {
86
- id: "2",
87
- aggregateId: "agg-2",
88
- aggregateType: "widget",
89
- type: "widget.updated",
90
- createdBy: "user-2",
91
- createdAt: "2026-08-02T00:00:00.000Z",
92
- payload: {},
93
- },
94
- {
95
- id: "3",
96
- aggregateId: "agg-3",
97
- aggregateType: "widget",
98
- type: "widget.deleted",
99
- createdBy: "user-missing",
100
- createdAt: "2026-08-03T00:00:00.000Z",
101
- payload: {},
102
- },
103
- {
104
- id: "4",
105
- aggregateId: "agg-4",
106
- aggregateType: "widget",
107
- type: "widget.synced",
108
- createdBy: "system",
109
- createdAt: "2026-08-04T00:00:00.000Z",
110
- payload: {},
111
- },
112
- {
113
- id: "5",
114
- aggregateId: "agg-5",
115
- aggregateType: "widget",
116
- type: "widget.archived",
117
- createdBy: "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
118
- createdAt: "2026-08-05T00:00:00.000Z",
119
- payload: {},
120
- },
121
- ];
122
-
123
- const MEMBERS: readonly MemberRowFixture[] = [
124
- { userId: "user-1", email: "alice@example.com", displayName: "Alice Example" },
125
- { userId: "user-2", email: "bob@example.com", displayName: null },
126
- ];
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 {
144
- const statusStore = createStore<DispatcherStatus>("online");
145
- const query = (async (type: string) => {
146
- if (type === AuditQueries.list) {
147
- return { isSuccess: true, data: { rows: AUDIT_ROWS, nextBefore: null } };
148
- }
149
- if (type === AuditQueries.details) {
150
- return { isSuccess: true, data: options.detail ?? null };
151
- }
152
- if (type === TenantQueries.members) {
153
- return options.membersResult ?? { isSuccess: true, data: MEMBERS };
154
- }
155
- return { isSuccess: true, data: null };
156
- }) as unknown as Dispatcher["query"];
157
- return {
158
- write: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["write"],
159
- query,
160
- batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
161
- statusStore,
162
- async *stream() {},
163
- pendingWrites: () => [],
164
- pendingFiles: () => [],
165
- } as unknown as Dispatcher; // @cast-boundary test-stub
166
- }
167
-
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();
174
- const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
175
- <TokensProvider value={stubTokens}>
176
- <LocaleProvider
177
- resolver={stubResolver}
178
- fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
179
- >
180
- <PrimitivesProvider value={defaultPrimitives}>
181
- <NavProvider value={nav}>
182
- <LiveEventsProvider value={stubLiveEvents}>
183
- <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
184
- </LiveEventsProvider>
185
- </NavProvider>
186
- </PrimitivesProvider>
187
- </LocaleProvider>
188
- </TokensProvider>
189
- );
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 });
200
- }
201
-
202
- describe("AuditLogScreen — actor name resolution", () => {
203
- test("known member with displayName → cell shows the display name", async () => {
204
- const view = renderScreen();
205
- await waitFor(() => {
206
- const cell = view.queryByTestId("cell-1-actor");
207
- if (cell === null) throw new Error("actor cell not rendered yet");
208
- });
209
- expect(view.getByTestId("cell-1-actor").textContent).toBe("Alice Example");
210
- });
211
-
212
- test("known member without displayName → cell falls back to email", async () => {
213
- const view = renderScreen();
214
- await waitFor(() => {
215
- const cell = view.queryByTestId("cell-2-actor");
216
- if (cell === null) throw new Error("actor cell not rendered yet");
217
- });
218
- expect(view.getByTestId("cell-2-actor").textContent).toBe("bob@example.com");
219
- });
220
-
221
- test("createdBy not in members list → cell shows Unknown actor, never the raw id", async () => {
222
- const view = renderScreen();
223
- await waitFor(() => {
224
- const cell = view.queryByTestId("cell-3-actor");
225
- if (cell === null) throw new Error("actor cell not rendered yet");
226
- });
227
- const cell = view.getByTestId("cell-3-actor");
228
- expect(cell.textContent).toBe("Unknown actor");
229
- expect(cell.textContent).not.toBe("user-missing");
230
- });
231
-
232
- test("table has no aggregate column", async () => {
233
- const view = renderScreen();
234
- await waitFor(() => {
235
- const cell = view.queryByTestId("cell-1-actor");
236
- if (cell === null) throw new Error("table not rendered yet");
237
- });
238
- expect(view.queryByTestId("cell-1-aggregate")).toBeNull();
239
- expect(view.container.textContent).not.toContain("agg-1");
240
- });
241
-
242
- test("createdBy is the system literal → cell shows the System label", async () => {
243
- const view = renderScreen();
244
- await waitFor(() => {
245
- const cell = view.queryByTestId("cell-4-actor");
246
- if (cell === null) throw new Error("actor cell not rendered yet");
247
- });
248
- expect(view.getByTestId("cell-4-actor").textContent).toBe("System");
249
- });
250
-
251
- test("createdBy is an unresolvable UUID → Unknown actor label, raw UUID never rendered", async () => {
252
- const view = renderScreen();
253
- await waitFor(() => {
254
- const cell = view.queryByTestId("cell-5-actor");
255
- if (cell === null) throw new Error("actor cell not rendered yet");
256
- });
257
- const cell = view.getByTestId("cell-5-actor");
258
- expect(cell.textContent).toBe("Unknown actor");
259
- expect(view.container.textContent).not.toContain("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
260
- });
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("Unknown actor");
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
- });
@@ -1,173 +0,0 @@
1
- // @runtime client
2
- // Single audit event detail. Route entityId = event-store id (bigint string).
3
-
4
- import {
5
- formatWhen,
6
- useDispatcher,
7
- useNav,
8
- usePrimitives,
9
- useTranslation,
10
- } from "@cosmicdrift/kumiko-renderer";
11
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
12
- import { type ReactNode, useCallback, useEffect, useState } from "react";
13
- import { TenantQueries } from "../../tenant/constants";
14
- import { ANONYMOUS_USER_ID, AuditQueries, SYSTEM_ACTOR_IDS } from "../constants";
15
-
16
- type AuditDetail = {
17
- readonly id: string;
18
- readonly aggregateId: string;
19
- readonly aggregateType: string;
20
- readonly type: string;
21
- readonly createdBy: string;
22
- readonly createdAt: string;
23
- readonly payload: Record<string, unknown>;
24
- readonly metadata: Record<string, unknown>;
25
- };
26
-
27
- type MemberRow = {
28
- readonly userId: string;
29
- readonly email: string | null;
30
- readonly displayName: string | null;
31
- };
32
-
33
- type State =
34
- | { readonly kind: "loading" }
35
- | { readonly kind: "missing" }
36
- | { readonly kind: "error"; readonly message: string }
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
- };
46
-
47
- export function AuditLogDetailScreen(): ReactNode {
48
- const t = useTranslation();
49
- const { Banner, Card, Text } = usePrimitives();
50
- const dispatcher = useDispatcher();
51
- const nav = useNav();
52
- const eventId = nav.route?.entityId;
53
- const [state, setState] = useState<State>({ kind: "loading" });
54
-
55
- const load = useCallback(async (): Promise<void> => {
56
- if (eventId === undefined || eventId === "") {
57
- setState({ kind: "missing" });
58
- return;
59
- }
60
- setState({ kind: "loading" });
61
- const res = await dispatcher.query<AuditDetail | null>(AuditQueries.details, { id: eventId });
62
- if (!res.isSuccess) {
63
- setState({ kind: "error", message: res.error.message });
64
- return;
65
- }
66
- if (res.data === null) {
67
- setState({ kind: "missing" });
68
- return;
69
- }
70
- const event = res.data;
71
- if (SYSTEM_ACTOR_IDS.has(event.createdBy)) {
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
- });
79
- const member = membersRes.isSuccess
80
- ? membersRes.data.find((m) => m.userId === event.createdBy)
81
- : undefined;
82
- setState({
83
- kind: "ready",
84
- event,
85
- memberLabel: member ? (member.displayName ?? member.email ?? "") : "",
86
- isSystemActor: false,
87
- });
88
- }, [dispatcher, eventId]);
89
-
90
- // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
91
- useEffect(() => {
92
- void load();
93
- }, [load]);
94
-
95
- if (state.kind === "loading") {
96
- return (
97
- <FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
98
- <Text variant="small">{t("audit.log.detail.loading")}</Text>
99
- </FormScreenShell>
100
- );
101
- }
102
-
103
- if (state.kind === "missing") {
104
- return (
105
- <FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
106
- <Banner variant="error">{t("audit.log.detail.missing")}</Banner>
107
- </FormScreenShell>
108
- );
109
- }
110
-
111
- if (state.kind === "error") {
112
- return (
113
- <FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
114
- <Banner variant="error">{state.message}</Banner>
115
- </FormScreenShell>
116
- );
117
- }
118
-
119
- const { event, memberLabel, isSystemActor } = state;
120
- const actorName = isSystemActor
121
- ? t("audit.log.actor.system")
122
- : event.createdBy === ANONYMOUS_USER_ID
123
- ? t("audit.log.actor.anonymous")
124
- : memberLabel || t("audit.log.actor.unknown");
125
-
126
- return (
127
- <FormScreenShell
128
- testId="audit-log-detail-screen"
129
- maxWidth="3xl"
130
- className="flex flex-col gap-6"
131
- >
132
- <Card slots={{ title: event.type }}>
133
- <dl className="grid gap-3 text-sm">
134
- <div>
135
- <dt className="font-medium">{t("audit.log.col.when")}</dt>
136
- <dd data-testid="audit-detail-when">{formatWhen(event.createdAt)}</dd>
137
- </div>
138
- <div>
139
- <dt className="font-medium">{t("audit.log.col.aggregate")}</dt>
140
- <dd data-testid="audit-detail-aggregate">
141
- {event.aggregateType} / {event.aggregateId}
142
- </dd>
143
- </div>
144
- <div>
145
- <dt className="font-medium">{t("audit.log.col.actor")}</dt>
146
- <dd data-testid="audit-detail-actor">{actorName}</dd>
147
- </div>
148
- <div>
149
- <dt className="font-medium">{t("audit.log.detail.field.id")}</dt>
150
- <dd>
151
- <Text variant="code">{event.id}</Text>
152
- </dd>
153
- </div>
154
- </dl>
155
- </Card>
156
-
157
- <Card slots={{ title: t("audit.log.detail.payload") }}>
158
- <pre
159
- className="max-h-96 overflow-auto whitespace-pre-wrap break-all text-xs"
160
- data-testid="audit-detail-payload"
161
- >
162
- {JSON.stringify(event.payload, null, 2)}
163
- </pre>
164
- </Card>
165
-
166
- <Card slots={{ title: t("audit.log.detail.metadata") }}>
167
- <pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all text-xs">
168
- {JSON.stringify(event.metadata, null, 2)}
169
- </pre>
170
- </Card>
171
- </FormScreenShell>
172
- );
173
- }