@cosmicdrift/kumiko-bundled-features 0.211.0 → 0.212.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 (62) 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/feature.ts +1 -0
  12. package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
  13. package/src/auth-email-password/web/auth-gate.tsx +1 -0
  14. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
  15. package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
  16. package/src/auth-email-password/web/session.tsx +1 -0
  17. package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
  18. package/src/auth-mfa/feature.ts +1 -0
  19. package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
  20. package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
  21. package/src/compliance-profiles/feature.ts +1 -5
  22. package/src/crypto-shredding/changes.json +2 -1
  23. package/src/custom-fields/__tests__/feature.test.ts +42 -0
  24. package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
  25. package/src/custom-fields/constants.ts +6 -0
  26. package/src/custom-fields/feature.ts +56 -10
  27. package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
  28. package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
  29. package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
  30. package/src/delivery/feature.ts +1 -3
  31. package/src/feature-toggles/feature.ts +1 -0
  32. package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
  33. package/src/folders/web/folder-manager.tsx +1 -0
  34. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
  35. package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
  36. package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
  37. package/src/jobs/feature.ts +25 -1
  38. package/src/jobs/handlers/detail.query.ts +13 -1
  39. package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
  40. package/src/jobs/job-run-logger.ts +68 -18
  41. package/src/jobs/web/job-run-detail-screen.tsx +1 -0
  42. package/src/page-render/web.ts +1 -0
  43. package/src/personal-access-tokens/feature.ts +1 -0
  44. package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
  45. package/src/secrets/constants.ts +12 -0
  46. package/src/secrets/feature.ts +35 -9
  47. package/src/secrets/handlers/delete.write.ts +23 -16
  48. package/src/secrets/handlers/list.query.ts +37 -30
  49. package/src/secrets/handlers/set.write.ts +43 -36
  50. package/src/secrets/index.ts +5 -0
  51. package/src/seo/feature.ts +1 -0
  52. package/src/tags/feature.ts +1 -0
  53. package/src/tags/web/tag-chip.tsx +2 -0
  54. package/src/tags/web/tag-manager.tsx +2 -1
  55. package/src/tags/web/tag-picker.tsx +1 -0
  56. package/src/template-resolver/web/client-plugin.tsx +2 -0
  57. package/src/tier-engine/feature.ts +2 -0
  58. package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
  59. package/src/user-data-rights/feature.ts +2 -0
  60. package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  61. package/src/user-profile/i18n.ts +1 -0
  62. 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.212.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.212.0",
130
+ "@cosmicdrift/kumiko-framework": "0.212.0",
131
+ "@cosmicdrift/kumiko-headless": "0.212.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.212.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.212.0",
134
+ "@cosmicdrift/kumiko-types": "0.212.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.212.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)}
@@ -169,6 +169,7 @@ export function createAuthEmailPasswordFeature(
169
169
  r.describe(
170
170
  "Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows — password reset, email verification, magic-link self-signup, tenant invite, and account-unlock — are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`, `accountUnlock`) to `createAuthEmailPasswordFeature(opts)`. All five magic-link flows dispatch their mail through the `delivery` feature via `ctx.notify`, so mounting any of them additionally requires `delivery`. Tokens are HMAC-signed (reset/verify/unlock) or opaque-random in Redis (signup/invite). `accountUnlock` is the self-service escape hatch for `accountLockout`'s monotonic failure-counter (#1266): confirming the mailed token clears the Redis lockout state without touching the user entity. Requires the `user` and `tenant` features, and declares `JWT_SECRET` (≥ 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
171
171
  );
172
+ // kumiko-lint-ignore i18n-ui-strings uiHints labels are feature-registry metadata for the admin feature catalog, not yet wired to the i18n system, see #2312
172
173
  r.uiHints({
173
174
  displayLabel: "Auth \u00b7 Email + Password",
174
175
  category: "identity",
@@ -50,6 +50,7 @@ export type AuthCardProps = {
50
50
  readonly children: ReactNode;
51
51
  };
52
52
 
53
+ // kumiko-lint-ignore no-custom-primitives Wraps usePrimitives().Card, pure domain name for the auth-screen chrome, not a primitive reimplementation
53
54
  export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNode {
54
55
  const { Card } = usePrimitives();
55
56
  const shell = useAuthShell() ?? defaultAuthShell;
@@ -98,6 +99,7 @@ export function parseUrlToken(paramName = "token"): string {
98
99
  // (server-injected token) short-circuits both the URL read and the scrub.
99
100
  export function useUrlToken(override?: string, paramName = "token"): string {
100
101
  const [token] = useState(() => override ?? parseUrlToken(paramName));
102
+ // kumiko-lint-ignore no-raw-hooks DOM integration: history.replaceState scrubs the token from browser history once, no framework query/mutation equivalent
101
103
  useEffect(() => {
102
104
  if (override !== undefined) return;
103
105
  if (typeof window === "undefined") return;
@@ -76,6 +76,7 @@ export function createLoginRoute(
76
76
  readonly accountLabel: string;
77
77
  } | null>(null);
78
78
 
79
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
79
80
  useEffect(() => {
80
81
  if (status === "authenticated") onAuthenticated?.();
81
82
  }, [status]);
@@ -33,6 +33,7 @@ export function ConfirmAccountUnlockScreen({
33
33
  const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
34
34
  const startedRef = useRef(false);
35
35
 
36
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
36
37
  useEffect(() => {
37
38
  if (status !== "verifying") return;
38
39
  if (startedRef.current) return;
@@ -61,6 +61,7 @@ export function InviteAcceptScreen({
61
61
  const acceptLoggedIn = async (): Promise<void> => {
62
62
  setSubmitting(true);
63
63
  setError(null);
64
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
64
65
  const res = await fetch("/api/auth/invite-accept", {
65
66
  method: "POST",
66
67
  credentials: "same-origin",
@@ -83,6 +84,7 @@ export function InviteAcceptScreen({
83
84
  const acceptAnonExisting = async (): Promise<void> => {
84
85
  setSubmitting(true);
85
86
  setError(null);
87
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
86
88
  const res = await fetch("/api/auth/invite-accept-with-login", {
87
89
  method: "POST",
88
90
  credentials: "same-origin",
@@ -105,6 +107,7 @@ export function InviteAcceptScreen({
105
107
  const acceptAnonNew = async (): Promise<void> => {
106
108
  setSubmitting(true);
107
109
  setError(null);
110
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
108
111
  const res = await fetch("/api/auth/invite-signup-complete", {
109
112
  method: "POST",
110
113
  credentials: "same-origin",
@@ -128,6 +128,7 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
128
128
  setState(next);
129
129
  }, []);
130
130
 
131
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
131
132
  useEffect(() => {
132
133
  void doRefresh();
133
134
  }, [doRefresh]);
@@ -36,6 +36,7 @@ export function VerifyEmailScreen({
36
36
  const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
37
37
  const startedRef = useRef(false);
38
38
 
39
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
39
40
  useEffect(() => {
40
41
  if (status !== "verifying") return;
41
42
  if (startedRef.current) return;