@cosmicdrift/kumiko-bundled-features 0.220.1 → 0.222.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 (151) hide show
  1. package/package.json +10 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
  3. package/src/audit/__tests__/audit-log-screen.test.tsx +85 -11
  4. package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
  5. package/src/audit/__tests__/audit.integration.test.ts +3 -1
  6. package/src/audit/__tests__/constants.test.ts +13 -0
  7. package/src/audit/constants.ts +10 -1
  8. package/src/audit/feature.ts +2 -0
  9. package/src/audit/i18n.ts +2 -0
  10. package/src/audit/web/audit-log-detail-screen.tsx +35 -15
  11. package/src/audit/web/audit-log-screen.tsx +45 -33
  12. package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
  13. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
  14. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
  15. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
  16. package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  18. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
  19. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
  20. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
  21. package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
  22. package/src/auth-email-password/handlers/invite-create.write.ts +15 -10
  23. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
  24. package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
  25. package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
  26. package/src/auth-email-password/i18n.ts +1 -0
  27. package/src/auth-email-password/magic-link-mail.ts +17 -1
  28. package/src/auth-email-password/seeding.ts +3 -1
  29. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
  30. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
  31. package/src/auth-mfa/hmac-token-codec.ts +75 -0
  32. package/src/auth-mfa/mfa-challenge-token.ts +10 -65
  33. package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
  34. package/src/cap-counter/stock-cap-guard.ts +1 -1
  35. package/src/channel-email/__tests__/email-channel.test.ts +19 -0
  36. package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
  37. package/src/channel-email/email-channel.ts +14 -7
  38. package/src/channel-email/pii-guard.ts +8 -0
  39. package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
  40. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  41. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  42. package/src/crypto-shredding/constants.ts +1 -0
  43. package/src/crypto-shredding/feature.ts +6 -1
  44. package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
  45. package/src/crypto-shredding/index.ts +2 -0
  46. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
  47. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  48. package/src/custom-fields/constants.ts +4 -3
  49. package/src/custom-fields/feature.ts +7 -8
  50. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  51. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  52. package/src/delivery/db/queries/preferences.ts +3 -2
  53. package/src/delivery/handlers/log.query.ts +1 -0
  54. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  55. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  56. package/src/file-derivatives/feature.ts +8 -3
  57. package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
  58. package/src/file-provider-s3/feature.ts +1 -2
  59. package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
  60. package/src/files-provider-s3/s3-provider.ts +45 -0
  61. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
  62. package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
  63. package/src/files-tenant-data/changes.json +1 -0
  64. package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
  65. package/src/files-tenant-data/hooks.ts +93 -0
  66. package/src/files-tenant-data/index.ts +58 -0
  67. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  68. package/src/form-draft/db/queries/cleanup.ts +17 -0
  69. package/src/form-draft/handlers/cleanup.job.ts +51 -30
  70. package/src/form-draft/handlers/discard.write.ts +33 -15
  71. package/src/form-draft/handlers/get.query.ts +4 -2
  72. package/src/form-draft/handlers/list.query.ts +4 -1
  73. package/src/form-draft/lookup.ts +12 -5
  74. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  75. package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
  76. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  77. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +286 -23
  78. package/src/jobs/constants.ts +1 -0
  79. package/src/jobs/feature.ts +7 -2
  80. package/src/jobs/handlers/detail.query.ts +22 -11
  81. package/src/jobs/handlers/list.query.ts +17 -6
  82. package/src/jobs/handlers/retry.write.ts +26 -6
  83. package/src/jobs/i18n.ts +3 -0
  84. package/src/jobs/job-run-logger.ts +78 -36
  85. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  86. package/src/mail-transport-smtp/feature.ts +1 -2
  87. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  88. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  89. package/src/notes-history/handlers/add-note.write.ts +7 -3
  90. package/src/notes-history/web/notes-section.tsx +10 -2
  91. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  92. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  93. package/src/secrets/feature.ts +9 -2
  94. package/src/sessions/session-callbacks.ts +1 -0
  95. package/src/shared/index.ts +1 -0
  96. package/src/shared/map-with-concurrency.test.ts +2 -2
  97. package/src/shared/session-field.ts +10 -0
  98. package/src/shared/session-locale-field.test.ts +9 -0
  99. package/src/shared/session-locale-field.ts +4 -1
  100. package/src/shared/session-timezone-field.ts +2 -1
  101. package/src/subscription-stripe/feature.ts +5 -11
  102. package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
  103. package/src/tags/feature.ts +7 -4
  104. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  105. package/src/template-resolver/seeding.ts +78 -6
  106. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  107. package/src/template-resolver/web/client-plugin.tsx +2 -1
  108. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  109. package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
  110. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
  111. package/src/tenant/changes.json +1 -1
  112. package/src/tenant/constants.ts +5 -1
  113. package/src/tenant/handlers/members.query.ts +6 -1
  114. package/src/tenant/handlers/remove-member.write.ts +12 -0
  115. package/src/tenant/handlers/team-list.query.ts +2 -2
  116. package/src/tenant/handlers/update-member-roles.write.ts +11 -38
  117. package/src/tenant/i18n.ts +2 -2
  118. package/src/tenant/last-tenant-admin.ts +39 -0
  119. package/src/tenant/screens.ts +2 -0
  120. package/src/tenant/seeding.ts +3 -1
  121. package/src/tenant-lifecycle/feature.ts +1 -0
  122. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
  123. package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  124. package/src/tenant-lifecycle/stages.ts +5 -0
  125. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  126. package/src/tenant-settings/feature.ts +6 -30
  127. package/src/tier-engine/web/tier-admin-screen.tsx +1 -1
  128. package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
  129. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  130. package/src/user/__tests__/user.integration.test.ts +156 -118
  131. package/src/user/changes.json +12 -5
  132. package/src/user/handlers/create.write.ts +5 -4
  133. package/src/user/handlers/detail.query.ts +24 -2
  134. package/src/user/handlers/list.query.ts +19 -26
  135. package/src/user/handlers/update-roles.ts +18 -5
  136. package/src/user/handlers/update.write.ts +6 -5
  137. package/src/user/roles-input-schema.ts +20 -0
  138. package/src/user/schema/user.ts +2 -3
  139. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  140. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  141. package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
  142. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
  143. package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
  144. package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
  145. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
  146. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  147. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +33 -6
  148. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  149. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
  150. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
  151. 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.220.1",
3
+ "version": "0.222.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>",
@@ -71,6 +71,7 @@
71
71
  "./file-derivatives": "./src/file-derivatives/index.ts",
72
72
  "./derivatives-sharp": "./src/derivatives-sharp/index.ts",
73
73
  "./files": "./src/files/index.ts",
74
+ "./files-tenant-data": "./src/files-tenant-data/index.ts",
74
75
  "./user-data-rights": "./src/user-data-rights/index.ts",
75
76
  "./user-data-rights/web": "./src/user-data-rights/web/index.ts",
76
77
  "./user-data-rights-defaults": "./src/user-data-rights-defaults/index.ts",
@@ -126,12 +127,12 @@
126
127
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
128
  },
128
129
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.220.1",
130
- "@cosmicdrift/kumiko-framework": "0.220.1",
131
- "@cosmicdrift/kumiko-headless": "0.220.1",
132
- "@cosmicdrift/kumiko-renderer": "0.220.1",
133
- "@cosmicdrift/kumiko-renderer-web": "0.220.1",
134
- "@cosmicdrift/kumiko-types": "0.220.1",
130
+ "@cosmicdrift/kumiko-dispatcher-live": "0.222.0",
131
+ "@cosmicdrift/kumiko-framework": "0.222.0",
132
+ "@cosmicdrift/kumiko-headless": "0.222.0",
133
+ "@cosmicdrift/kumiko-renderer": "0.222.0",
134
+ "@cosmicdrift/kumiko-renderer-web": "0.222.0",
135
+ "@cosmicdrift/kumiko-types": "0.222.0",
135
136
  "@mollie/api-client": "^4.5.0",
136
137
  "@node-rs/argon2": "^2.0.2",
137
138
  "@types/mailparser": "^3.4.6",
@@ -160,7 +161,7 @@
160
161
  "devDependencies": {
161
162
  "@testing-library/user-event": "^14.6.1",
162
163
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.220.1",
164
- "@cosmicdrift/kumiko-locale-es": "0.220.1"
164
+ "@cosmicdrift/kumiko-locale-de": "0.222.0",
165
+ "@cosmicdrift/kumiko-locale-es": "0.222.0"
165
166
  }
166
167
  }
@@ -294,7 +294,7 @@ describe("access-invalidation event consumer", () => {
294
294
  {
295
295
  id: userId,
296
296
  version: detail["version"],
297
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
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
- function makeDispatcher(): Dispatcher {
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 renderScreen(): ReturnType<typeof render> {
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={stubNav}>
181
+ <NavProvider value={nav}>
158
182
  <LiveEventsProvider value={stubLiveEvents}>
159
- <DispatcherProvider dispatcher={makeDispatcher()}>{children}</DispatcherProvider>
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(<AuditLogScreen />, { wrapper });
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", () => {
@@ -185,14 +218,14 @@ describe("AuditLogScreen — actor name resolution", () => {
185
218
  expect(view.getByTestId("cell-2-actor").textContent).toBe("bob@example.com");
186
219
  });
187
220
 
188
- test("createdBy not in members list → cell is empty, never the raw id", async () => {
221
+ test("createdBy not in members list → cell shows Unknown actor, never the raw id", async () => {
189
222
  const view = renderScreen();
190
223
  await waitFor(() => {
191
224
  const cell = view.queryByTestId("cell-3-actor");
192
225
  if (cell === null) throw new Error("actor cell not rendered yet");
193
226
  });
194
227
  const cell = view.getByTestId("cell-3-actor");
195
- expect(cell.textContent).toBe("");
228
+ expect(cell.textContent).toBe("Unknown actor");
196
229
  expect(cell.textContent).not.toBe("user-missing");
197
230
  });
198
231
 
@@ -215,14 +248,55 @@ describe("AuditLogScreen — actor name resolution", () => {
215
248
  expect(view.getByTestId("cell-4-actor").textContent).toBe("System");
216
249
  });
217
250
 
218
- test("createdBy is an unresolvable UUID → cell stays empty, raw UUID never rendered", async () => {
251
+ test("createdBy is an unresolvable UUID → Unknown actor label, raw UUID never rendered", async () => {
219
252
  const view = renderScreen();
220
253
  await waitFor(() => {
221
254
  const cell = view.queryByTestId("cell-5-actor");
222
255
  if (cell === null) throw new Error("actor cell not rendered yet");
223
256
  });
224
257
  const cell = view.getByTestId("cell-5-actor");
225
- expect(cell.textContent).toBe("");
258
+ expect(cell.textContent).toBe("Unknown actor");
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("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
+ });
@@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
4
  import { createConfigFeature } from "../../config/feature";
5
+ import { createTenantFeature } from "../../tenant/feature";
5
6
  import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
6
7
  import { createAuditFeature } from "../feature";
7
8
 
8
9
  describe("audit log screen + handler access alignment", () => {
9
- const features = [createConfigFeature(), createAuditFeature()];
10
+ const features = [createConfigFeature(), createTenantFeature(), createAuditFeature()];
10
11
 
11
12
  test("boot-validates with audit-log screen registered", () => {
12
13
  expect(() => validateBoot(features)).not.toThrow();
@@ -21,6 +21,8 @@ import {
21
21
  unsafeCreateEntityTable,
22
22
  } from "@cosmicdrift/kumiko-framework/stack";
23
23
  import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
24
+ import { createConfigFeature } from "../../config";
25
+ import { createTenantFeature } from "../../tenant";
24
26
  import { AuditQueries } from "../constants";
25
27
  import { createAuditFeature } from "../feature";
26
28
 
@@ -59,7 +61,7 @@ const otherTenantAdmin: SessionUser = createTestUser({
59
61
 
60
62
  beforeAll(async () => {
61
63
  stack = await setupTestStack({
62
- features: [widgetFeature, createAuditFeature()],
64
+ features: [widgetFeature, createConfigFeature(), createTenantFeature(), createAuditFeature()],
63
65
  });
64
66
  await unsafeCreateEntityTable(stack.db, widgetEntity);
65
67
  });
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SYSTEM_ACTOR_ID, SYSTEM_ACTOR_IDS } 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
+
9
+ test("SYSTEM_ACTOR_IDS includes createSystemUser nil UUID", () => {
10
+ expect(SYSTEM_ACTOR_IDS.has(SYSTEM_ACTOR_ID)).toBe(true);
11
+ expect(SYSTEM_ACTOR_IDS.has("00000000-0000-0000-0000-000000000000")).toBe(true);
12
+ });
13
+ });
@@ -2,9 +2,18 @@
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). */
5
+ /** Literal `createdBy` written by hand-built system actors (secrets/jobs/…). */
6
6
  export const SYSTEM_ACTOR_ID = "system" as const;
7
7
 
8
+ /** Literal `createdBy` for anonymous / unauthenticated actors. */
9
+ export const ANONYMOUS_USER_ID = "anonymous" as const;
10
+
11
+ /** All `createdBy` values that mean "system" in the audit UI (nil-UUID from createSystemUser + literal). */
12
+ export const SYSTEM_ACTOR_IDS: ReadonlySet<string> = new Set([
13
+ SYSTEM_ACTOR_ID,
14
+ "00000000-0000-0000-0000-000000000000",
15
+ ]);
16
+
8
17
  export const AuditQueries = {
9
18
  list: "audit:query:list",
10
19
  details: "audit:query:details",
@@ -31,6 +31,8 @@ export function createAuditFeature(): FeatureDefinition {
31
31
  recommended: false,
32
32
  });
33
33
  r.translations({ keys: AUDIT_I18N });
34
+ // Screens resolve actor names via tenant:query:members.
35
+ r.requires("tenant");
34
36
 
35
37
  const queries = {
36
38
  list: r.queryHandler(listQuery),
package/src/audit/i18n.ts CHANGED
@@ -17,6 +17,8 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
17
17
  "audit.log.col.aggregate": { en: "Aggregate" },
18
18
  "audit.log.col.actor": { en: "Actor" },
19
19
  "audit.log.actor.system": { en: "System" },
20
+ "audit.log.actor.anonymous": { en: "Anonymous" },
21
+ "audit.log.actor.unknown": { en: "Unknown actor" },
20
22
  "audit.log.filter.eventType": { en: "Event type" },
21
23
  "audit.log.filter.aggregateType": { en: "Aggregate type" },
22
24
  "audit.log.filter.from": { en: "From" },
@@ -11,7 +11,7 @@ import {
11
11
  import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
12
12
  import { type ReactNode, useCallback, useEffect, useState } from "react";
13
13
  import { TenantQueries } from "../../tenant/constants";
14
- import { AuditQueries, SYSTEM_ACTOR_ID } from "../constants";
14
+ import { ANONYMOUS_USER_ID, AuditQueries, SYSTEM_ACTOR_IDS } from "../constants";
15
15
 
16
16
  type AuditDetail = {
17
17
  readonly id: string;
@@ -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
- | { readonly kind: "ready"; readonly event: AuditDetail; readonly actorName: 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
+ };
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
- const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {});
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
+ });
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
- actorName:
71
- event.createdBy === SYSTEM_ACTOR_ID
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, t]);
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,25 @@ 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, actorName } = state;
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");
109
125
 
110
126
  return (
111
- <FormScreenShell testId="audit-log-detail-screen" className="flex flex-col gap-6">
127
+ <FormScreenShell
128
+ testId="audit-log-detail-screen"
129
+ maxWidth="3xl"
130
+ className="flex flex-col gap-6"
131
+ >
112
132
  <Card slots={{ title: event.type }}>
113
133
  <dl className="grid gap-3 text-sm">
114
134
  <div>
@@ -14,7 +14,12 @@ import {
14
14
  import { Filter, RotateCcw } from "lucide-react";
15
15
  import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
16
16
  import { TenantQueries } from "../../tenant/constants";
17
- import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries, SYSTEM_ACTOR_ID } from "../constants";
17
+ import {
18
+ ANONYMOUS_USER_ID,
19
+ AUDIT_LOG_DETAIL_SCREEN_ID,
20
+ AuditQueries,
21
+ SYSTEM_ACTOR_IDS,
22
+ } from "../constants";
18
23
 
19
24
  type AuditRow = {
20
25
  readonly id: string;
@@ -36,7 +41,6 @@ type MemberRow = {
36
41
 
37
42
  type Filters = {
38
43
  readonly eventType: string;
39
- readonly aggregateType: string;
40
44
  readonly from: string;
41
45
  readonly to: string;
42
46
  };
@@ -51,7 +55,17 @@ type State =
51
55
  readonly names: ReadonlyMap<string, string>;
52
56
  };
53
57
 
54
- const EMPTY_FILTERS: Filters = { eventType: "", aggregateType: "", from: "", to: "" };
58
+ const EMPTY_FILTERS: Filters = { eventType: "", from: "", to: "" };
59
+
60
+ function actorLabel(
61
+ createdBy: string,
62
+ names: ReadonlyMap<string, string>,
63
+ t: (key: string) => string,
64
+ ): string {
65
+ if (SYSTEM_ACTOR_IDS.has(createdBy)) return t("audit.log.actor.system");
66
+ if (createdBy === ANONYMOUS_USER_ID) return t("audit.log.actor.anonymous");
67
+ return names.get(createdBy) ?? t("audit.log.actor.unknown");
68
+ }
55
69
 
56
70
  export function AuditLogScreen(): ReactNode {
57
71
  const t = useTranslation();
@@ -70,25 +84,37 @@ export function AuditLogScreen(): ReactNode {
70
84
  // mid-session and refetching on every "older" click is wasted work.
71
85
  const membersRef = useRef<Promise<ReadonlyMap<string, string>> | null>(null);
72
86
 
87
+ // Tenant/dispatcher identity can change without remounting — drop the cache.
88
+ const dispatcherIdentityRef = useRef(dispatcher);
89
+ if (dispatcherIdentityRef.current !== dispatcher) {
90
+ dispatcherIdentityRef.current = dispatcher;
91
+ membersRef.current = null;
92
+ }
93
+
73
94
  const load = useCallback(
74
95
  async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
75
96
  setState({ kind: "loading" });
76
97
  const f = overrideFilters ?? filtersRef.current;
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
- );
98
+ if (!membersRef.current) {
99
+ const p = dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {}).then((res) => {
100
+ if (!res.isSuccess) {
101
+ // Soft failure: clear so a later filter/page retry can refetch
102
+ // instead of freezing an empty actor column for the session.
103
+ if (membersRef.current === p) membersRef.current = null;
104
+ return new Map<string, string>();
105
+ }
106
+ return new Map(res.data.map((m) => [m.userId, m.displayName ?? m.email ?? ""] as const));
107
+ });
108
+ membersRef.current = p;
109
+ void p.catch(() => {
110
+ if (membersRef.current === p) membersRef.current = null;
111
+ });
112
+ }
84
113
  const [res, names] = await Promise.all([
85
114
  dispatcher.query<AuditResponse>(AuditQueries.list, {
86
115
  limit: 50,
87
116
  ...(cursor !== undefined && { before: cursor }),
88
117
  ...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
89
- ...(f.aggregateType.trim() !== "" && {
90
- aggregateType: f.aggregateType.trim(),
91
- }),
92
118
  ...(f.from !== "" && { from: toIsoStart(f.from) }),
93
119
  ...(f.to !== "" && { to: toIsoEnd(f.to) }),
94
120
  }),
@@ -130,7 +156,7 @@ export function AuditLogScreen(): ReactNode {
130
156
  return (
131
157
  <div className="w-full" data-testid="audit-log-screen">
132
158
  <div className="flex flex-wrap items-end gap-4 px-6 pt-6">
133
- <div className="min-w-40 flex-1">
159
+ <div className="min-w-40 max-w-64 flex-1">
134
160
  <Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
135
161
  <Input
136
162
  kind="text"
@@ -141,18 +167,7 @@ export function AuditLogScreen(): ReactNode {
141
167
  />
142
168
  </Field>
143
169
  </div>
144
- <div className="min-w-40 flex-1">
145
- <Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
146
- <Input
147
- kind="text"
148
- id="audit-filter-aggregate"
149
- name="audit-filter-aggregate"
150
- value={filters.aggregateType}
151
- onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
152
- />
153
- </Field>
154
- </div>
155
- <div className="min-w-40 flex-1">
170
+ <div className="min-w-40 max-w-64 flex-1">
156
171
  <Field id="audit-filter-from" label={t("audit.log.filter.from")}>
157
172
  <Input
158
173
  kind="date"
@@ -163,7 +178,7 @@ export function AuditLogScreen(): ReactNode {
163
178
  />
164
179
  </Field>
165
180
  </div>
166
- <div className="min-w-40 flex-1">
181
+ <div className="min-w-40 max-w-64 flex-1">
167
182
  <Field id="audit-filter-to" label={t("audit.log.filter.to")}>
168
183
  <Input
169
184
  kind="date"
@@ -184,7 +199,7 @@ export function AuditLogScreen(): ReactNode {
184
199
  }}
185
200
  testId="audit-log-apply-filters"
186
201
  >
187
- <Filter className="h-4 w-4" />
202
+ <Filter className="h-4 w-4" aria-hidden="true" />
188
203
  <span>{t("audit.log.filter.apply")}</span>
189
204
  </Button>
190
205
  <Button
@@ -197,7 +212,7 @@ export function AuditLogScreen(): ReactNode {
197
212
  }}
198
213
  testId="audit-log-reset-filters"
199
214
  >
200
- <RotateCcw className="h-4 w-4" />
215
+ <RotateCcw className="h-4 w-4" aria-hidden="true" />
201
216
  <span>{t("audit.log.filter.reset")}</span>
202
217
  </Button>
203
218
  </div>
@@ -217,10 +232,7 @@ export function AuditLogScreen(): ReactNode {
217
232
  values: {
218
233
  when: formatWhen(row.createdAt),
219
234
  type: row.type,
220
- actor:
221
- row.createdBy === SYSTEM_ACTOR_ID
222
- ? t("audit.log.actor.system")
223
- : (state.names.get(row.createdBy) ?? ""),
235
+ actor: actorLabel(row.createdBy, state.names, t),
224
236
  },
225
237
  }))}
226
238
  onRowClick={(row) => openDetail(row.id)}
@@ -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