@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
  3. package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
  4. package/src/audit/__tests__/constants.test.ts +8 -0
  5. package/src/audit/web/audit-log-detail-screen.tsx +30 -14
  6. package/src/audit/web/audit-log-screen.tsx +21 -7
  7. package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
  8. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
  9. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
  10. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
  11. package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
  12. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  13. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
  14. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
  15. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
  16. package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
  17. package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
  19. package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
  20. package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
  21. package/src/auth-email-password/i18n.ts +1 -0
  22. package/src/auth-email-password/magic-link-mail.ts +17 -1
  23. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
  24. package/src/auth-email-password/web/auth-client.ts +1 -7
  25. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
  26. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  27. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  28. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  29. package/src/custom-fields/constants.ts +4 -3
  30. package/src/custom-fields/feature.ts +7 -8
  31. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  32. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  33. package/src/delivery/db/queries/preferences.ts +3 -2
  34. package/src/delivery/handlers/log.query.ts +1 -0
  35. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
  36. package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
  37. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
  38. package/src/feature-toggles/feature.ts +24 -4
  39. package/src/feature-toggles/handlers/registered.query.ts +27 -16
  40. package/src/feature-toggles/i18n.ts +5 -0
  41. package/src/feature-toggles/web/client-plugin.tsx +1 -5
  42. package/src/feature-toggles/web/index.ts +0 -1
  43. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  44. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  45. package/src/file-derivatives/feature.ts +7 -6
  46. package/src/file-provider-s3/feature.ts +1 -2
  47. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  48. package/src/form-draft/handlers/cleanup.job.ts +31 -20
  49. package/src/form-draft/handlers/discard.write.ts +8 -8
  50. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  51. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  52. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
  53. package/src/jobs/feature.ts +7 -2
  54. package/src/jobs/handlers/detail.query.ts +22 -11
  55. package/src/jobs/handlers/list.query.ts +17 -6
  56. package/src/jobs/job-run-logger.ts +78 -36
  57. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  58. package/src/mail-transport-smtp/feature.ts +1 -2
  59. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  60. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  61. package/src/notes-history/handlers/add-note.write.ts +7 -3
  62. package/src/notes-history/web/notes-section.tsx +10 -2
  63. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  64. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  65. package/src/secrets/feature.ts +9 -2
  66. package/src/secrets/handlers/delete.write.ts +0 -4
  67. package/src/secrets/handlers/list.query.ts +0 -4
  68. package/src/secrets/handlers/set.write.ts +0 -4
  69. package/src/secrets/index.ts +3 -3
  70. package/src/shared/index.ts +1 -0
  71. package/src/shared/map-with-concurrency.test.ts +2 -2
  72. package/src/shared/session-field.ts +10 -0
  73. package/src/shared/session-locale-field.test.ts +9 -0
  74. package/src/shared/session-locale-field.ts +4 -1
  75. package/src/shared/session-timezone-field.ts +2 -1
  76. package/src/subscription-stripe/feature.ts +5 -11
  77. package/src/tags/__tests__/feature.test.ts +18 -5
  78. package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
  79. package/src/tags/constants.ts +3 -2
  80. package/src/tags/entity.ts +2 -0
  81. package/src/tags/feature.ts +20 -11
  82. package/src/tags/handlers/create-tag.write.ts +5 -2
  83. package/src/tags/handlers/delete-tag.write.ts +5 -2
  84. package/src/tags/handlers/update-tag.write.ts +5 -2
  85. package/src/tags/i18n.ts +4 -0
  86. package/src/tags/index.ts +2 -0
  87. package/src/tags/screens.ts +66 -0
  88. package/src/tags/web/client-plugin.tsx +1 -6
  89. package/src/tags/web/index.ts +1 -0
  90. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  91. package/src/template-resolver/seeding.ts +78 -6
  92. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  93. package/src/template-resolver/web/client-plugin.tsx +2 -1
  94. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  95. package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
  96. package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
  97. package/src/tenant/handlers/members.query.ts +6 -1
  98. package/src/tenant/handlers/team-list.query.ts +3 -1
  99. package/src/tenant/handlers/update-member-roles.write.ts +16 -2
  100. package/src/tenant/i18n.ts +2 -3
  101. package/src/tenant/screens.ts +3 -2
  102. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  103. package/src/tenant-settings/feature.ts +6 -30
  104. package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
  105. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  106. package/src/user/__tests__/user.integration.test.ts +156 -118
  107. package/src/user/handlers/create.write.ts +5 -4
  108. package/src/user/handlers/detail.query.ts +24 -2
  109. package/src/user/handlers/list.query.ts +19 -26
  110. package/src/user/handlers/update-roles.ts +23 -6
  111. package/src/user/handlers/update.write.ts +6 -5
  112. package/src/user/i18n.ts +0 -6
  113. package/src/user/roles-input-schema.ts +20 -0
  114. package/src/user/schema/user.ts +2 -3
  115. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  116. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  117. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  118. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
  119. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  120. package/src/user-profile/web/profile-screen.tsx +1 -1
  121. package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
@@ -71,9 +71,8 @@ export const userEntity = createEntity({
71
71
  format: "email",
72
72
  maxLength: 320,
73
73
  personal: "self",
74
- find: "exact",
75
- // Roster search needs email hits; find:"exact" alone is blind-index only.
76
- searchable: true,
74
+ // Roster search needs email hits — fuzzy expands to lookupable+searchable.
75
+ find: "fuzzy",
77
76
  access: { write: access.privileged },
78
77
  }),
79
78
 
@@ -34,6 +34,7 @@ import {
34
34
  unsafePushTables,
35
35
  } from "@cosmicdrift/kumiko-framework/stack";
36
36
  import {
37
+ expectSuccess,
37
38
  resetBlindIndexKeyForTests,
38
39
  resetPiiSubjectKmsForTests,
39
40
  resetTestTables,
@@ -447,8 +448,8 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
447
448
  systemUser,
448
449
  tenantDb,
449
450
  );
450
- expect(createResult.isSuccess).toBe(true);
451
- const invitationId = (createResult as { data: { id: string } }).data.id;
451
+ expectSuccess(createResult);
452
+ const invitationId = createResult.data.id;
452
453
 
453
454
  const rawRows = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
454
455
  `SELECT email, email_bidx FROM read_tenant_invitations WHERE id = $1`,
@@ -24,6 +24,7 @@ import {
24
24
  EXT_USER_DATA,
25
25
  type JobContext,
26
26
  SYSTEM_USER_ID,
27
+ type TenantId,
27
28
  type UserDataDeleteHook,
28
29
  } from "@cosmicdrift/kumiko-framework/engine";
29
30
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -245,16 +246,13 @@ describe("forget pipeline honours the effective tenant model", () => {
245
246
  // `tenant-membership.created` events, so FORGET_USER needs its own
246
247
  // `.created` event too, or the tenant would only ever show 1 distinct
247
248
  // historical member (CO_MEMBER) even after it truly had 2.
248
- // Explicit `by`: streamTenantFor(user) keys each event's OWN tenantId
249
- // column off the acting user, not the payload's tenantId — the
250
- // seedTenantMembership default (TestUsers.systemAdmin) lives on a
251
- // different tenant, which would emit .created under the wrong tenantId
252
- // and make it invisible to everHadMultipleMembers' tenant-scoped query.
249
+ // seedTenantMembership defaults `by` to createSystemUser(tenantId), so
250
+ // the stream tenant matches TENANT (unlike seedTenant, which defaults
251
+ // to TestUsers.systemAdmin).
253
252
  await seedTenantMembership(stack.db, {
254
253
  userId: FORGET_USER,
255
254
  tenantId: TENANT,
256
255
  roles: ["Member"],
257
- by: createSystemUser(TENANT),
258
256
  });
259
257
 
260
258
  // CO_MEMBER joins for real (emits tenant-membership.created) and is then
@@ -265,7 +263,6 @@ describe("forget pipeline honours the effective tenant model", () => {
265
263
  userId: CO_MEMBER,
266
264
  tenantId: TENANT,
267
265
  roles: ["Member"],
268
- by: createSystemUser(TENANT),
269
266
  });
270
267
  const deleteResult = await membershipExecutor.delete(
271
268
  { id: created.id },
@@ -299,6 +296,47 @@ describe("forget pipeline honours the effective tenant model", () => {
299
296
  // historical check must still resolve multi-user here.
300
297
  expect(await rowCount()).toBe(1);
301
298
  });
299
+
300
+ test("single-user history latch uses payload tenantId (cross-tenant SystemAdmin actor)", async () => {
301
+ await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c6");
302
+ await seed(stack.db).seedForgetUser(FORGET_USER);
303
+ const OTHER_TENANT = "dddddddd-dddd-4ddd-8ddd-0000000000aa" as TenantId;
304
+
305
+ // Membership events billed to a foreign actor tenant — mirrors real
306
+ // SystemAdmin addMember (event.tenant_id ≠ payload.tenantId).
307
+ await seedTenantMembership(stack.db, {
308
+ userId: FORGET_USER,
309
+ tenantId: TENANT,
310
+ roles: ["Member"],
311
+ by: createSystemUser(OTHER_TENANT),
312
+ });
313
+ const created = await seedTenantMembership(stack.db, {
314
+ userId: CO_MEMBER,
315
+ tenantId: TENANT,
316
+ roles: ["Member"],
317
+ by: createSystemUser(OTHER_TENANT),
318
+ });
319
+ const deleteResult = await membershipExecutor.delete(
320
+ { id: created.id },
321
+ createSystemUser(OTHER_TENANT),
322
+ createTenantDb(stack.db, TENANT, "system"),
323
+ );
324
+ if (!deleteResult.isSuccess) {
325
+ throw new Error(`test setup: co-member delete failed: ${deleteResult.error.code}`);
326
+ }
327
+
328
+ const result = await runForgetCleanup({
329
+ db: stack.db,
330
+ registry: stack.registry,
331
+ now: nowInstant(),
332
+ tenantModel: "single-user",
333
+ });
334
+
335
+ expect(result.errors).toHaveLength(0);
336
+ expect(result.processedUserIds).toContain(FORGET_USER);
337
+ // Payload-tenant latch must still see 2 historical members.
338
+ expect(await rowCount()).toBe(1);
339
+ });
302
340
  });
303
341
 
304
342
  describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel", () => {
@@ -41,6 +41,7 @@ import {
41
41
  import {
42
42
  type DbRunner,
43
43
  entityEventName,
44
+ executeRawQuery,
44
45
  nullBlindIndexesForSubject,
45
46
  } from "@cosmicdrift/kumiko-framework/db";
46
47
  import {
@@ -53,7 +54,6 @@ import {
53
54
  type UserDataDeleteStrategy,
54
55
  type UserDataStorageProvider,
55
56
  } from "@cosmicdrift/kumiko-framework/engine";
56
- import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
57
57
  import {
58
58
  purgeSearchDocumentsForSubject,
59
59
  type SearchAdapter,
@@ -519,12 +519,12 @@ async function runInSubTransaction(
519
519
  begin?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
520
520
  savepoint?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
521
521
  };
522
- // savepoint-FIRST — empirisch (Bun 1.4.0) sind die Flächen NICHT
523
- // mutually exclusive: eine TransactionSql exposed begin UND savepoint,
524
- // nur die Top-Level-Connection hat ausschließlich begin. begin-first
525
- // wählte im Tx-Fall das nested BEGIN (Prod-Incident-Klasse, s. Header);
526
- // savepoint-first trifft im Tx-Fall den Savepoint und fällt top-level
527
- // sauber auf begin zurück.
522
+ // savepoint-FIRST — empirisch (Bun 1.3.14, unter 1.4.0 nicht neu geprüft)
523
+ // sind die Flächen NICHT mutually exclusive: eine TransactionSql exposed
524
+ // begin UND savepoint, nur die Top-Level-Connection hat ausschließlich
525
+ // begin. begin-first wählte im Tx-Fall das nested BEGIN (Prod-Incident-
526
+ // Klasse, s. Header); savepoint-first trifft im Tx-Fall den Savepoint
527
+ // und fällt top-level sauber auf begin zurück.
528
528
  const open = runner.savepoint ?? runner.begin;
529
529
  if (!open) {
530
530
  throw new Error(
@@ -570,26 +570,36 @@ async function resolveEffectiveTenantModel(
570
570
  // enough: a tenant that ever had >1 distinct member must stay multi-user for
571
571
  // the forget path even after everyone but one is removed, or the sole
572
572
  // remaining member's forget request wipes the departed co-member's leftover
573
- // rows. LIMIT chosen generously for a nominally single-user tenant; hitting
574
- // it is itself a multi-user signal, so it also resolves to "multi-user".
575
- const MAX_MEMBERSHIP_CREATED_EVENTS = 1000;
576
-
573
+ // rows.
574
+ //
575
+ // Filter by payload->>'tenantId' (not the event column tenant_id): cross-
576
+ // tenant SystemAdmin adds land under the actor's tenant_id (#2347). SQL
577
+ // aggregation stays fail-safe when userId payloads are unreadable: more
578
+ // created-rows than distinct userIds ⇒ treat as multi-user.
577
579
  async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise<boolean> {
578
- const createdEvents = await selectMany<{ payload: Record<string, unknown> }>(
580
+ const eventType = entityEventName("tenant-membership", "created");
581
+ const rows = await executeRawQuery<{
582
+ event_count: number;
583
+ distinct_users: number;
584
+ }>(
579
585
  db,
580
- eventsTable,
581
- {
582
- tenantId,
583
- aggregateType: "tenant-membership",
584
- type: entityEventName("tenant-membership", "created"),
585
- },
586
- { limit: MAX_MEMBERSHIP_CREATED_EVENTS },
587
- );
588
- if (createdEvents.length >= MAX_MEMBERSHIP_CREATED_EVENTS) return true;
589
- const distinctUserIds = new Set(
590
- createdEvents.map((e) => e.payload["userId"]).filter((v): v is string => typeof v === "string"),
586
+ `SELECT
587
+ COUNT(*)::int AS event_count,
588
+ COUNT(DISTINCT payload->>'userId')::int AS distinct_users
589
+ FROM kumiko_events
590
+ WHERE aggregate_type = 'tenant-membership'
591
+ AND type = $1
592
+ AND payload->>'tenantId' = $2`,
593
+ [eventType, tenantId],
591
594
  );
592
- return distinctUserIds.size > 1;
595
+ const row = rows[0];
596
+ if (!row) return false;
597
+ if (row.distinct_users > 1) return true;
598
+ // Unreadable / missing userId payloads: fail-safe to multi-user
599
+ // (including a lone created-event with no readable userId).
600
+ if (row.event_count >= 1 && row.distinct_users < 1) return true;
601
+ if (row.event_count > 1 && row.distinct_users <= 1) return true;
602
+ return false;
593
603
  }
594
604
 
595
605
  // Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
@@ -273,6 +273,29 @@ describe("PrivacyCenterScreen", () => {
273
273
  assign.mockRestore();
274
274
  }
275
275
  });
276
+
277
+ test("Download-Button does not navigate when downloadByJob returns no url", async () => {
278
+ const assign = spyOn(window.location, "assign").mockImplementation(() => {});
279
+ try {
280
+ const { view } = renderCenter({
281
+ me: activeMe,
282
+ downloadUrl: undefined,
283
+ exportStatus: {
284
+ hasJob: true,
285
+ job: { id: "job-123", status: EXPORT_JOB_STATUS.Done, expiresAt: "2026-07-11T00:00:00Z" },
286
+ },
287
+ });
288
+ await waitForDownloadReady(view);
289
+ fireEvent.click(view.getByTestId("privacy-export-download"));
290
+ // Banner uses t(i18nKey) — assert the resolved EN copy, not the raw key.
291
+ await waitFor(() => {
292
+ expect(view.container.textContent).toContain("Download unavailable — please try again.");
293
+ });
294
+ expect(assign).not.toHaveBeenCalled();
295
+ } finally {
296
+ assign.mockRestore();
297
+ }
298
+ });
276
299
  });
277
300
 
278
301
  describe("QN-Drift-Pins (client-Konstanten vs. Feature-Originale)", () => {
@@ -387,7 +387,7 @@ export function PrivacyCenterScreen({
387
387
  };
388
388
 
389
389
  return (
390
- <FormScreenShell className="flex flex-col gap-6" testId="privacy-center-screen">
390
+ <FormScreenShell className="flex flex-col gap-6" testId="privacy-center-screen" maxWidth="3xl">
391
391
  <Heading variant="page">{t("userDataRights.privacyCenter.title")}</Heading>
392
392
  <p className="text-sm text-muted-foreground">{t("userDataRights.privacyCenter.intro")}</p>
393
393
  <ExportSection />
@@ -361,7 +361,7 @@ export function ProfileScreen(): ReactNode {
361
361
  };
362
362
 
363
363
  return (
364
- <FormScreenShell className="flex flex-col gap-6" testId="profile-screen">
364
+ <FormScreenShell className="flex flex-col gap-6" testId="profile-screen" maxWidth="3xl">
365
365
  <Heading variant="page">{t("profile.title")}</Heading>
366
366
  {/* Die zwei kurzen Konto-Forms teilen sich eine Reihe (md+); die
367
367
  Danger-Zone bleibt volle Breite darunter. */}
@@ -1,137 +0,0 @@
1
- // @runtime client
2
- import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
4
- import { type ReactNode, useCallback, useEffect, useState } from "react";
5
- import { FeatureToggleHandlers, FeatureToggleQueries } from "../constants";
6
-
7
- type ToggleRow = {
8
- readonly name: string;
9
- readonly toggleable: boolean;
10
- readonly default: boolean | null;
11
- readonly override: boolean | null;
12
- readonly effective: boolean | null;
13
- };
14
-
15
- type RegisteredResponse = {
16
- readonly items: readonly ToggleRow[];
17
- };
18
-
19
- type State =
20
- | { readonly kind: "loading" }
21
- | { readonly kind: "error"; readonly message: string }
22
- | { readonly kind: "ready"; readonly items: readonly ToggleRow[] };
23
-
24
- export function ToggleAdminScreen(): ReactNode {
25
- const t = useTranslation();
26
- const { Banner, Card, DataTable, Heading, Text } = usePrimitives();
27
- const dispatcher = useDispatcher();
28
- const [state, setState] = useState<State>({ kind: "loading" });
29
- const [actionError, setActionError] = useState<string | null>(null);
30
-
31
- const load = useCallback(async (): Promise<void> => {
32
- setState({ kind: "loading" });
33
- const res = await dispatcher.query<RegisteredResponse>(FeatureToggleQueries.registered, {});
34
- if (!res.isSuccess) {
35
- setState({ kind: "error", message: res.error.message });
36
- return;
37
- }
38
- setState({ kind: "ready", items: res.data.items });
39
- }, [dispatcher]);
40
-
41
- // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
42
- useEffect(() => {
43
- void load();
44
- }, [load]);
45
-
46
- const onSet = async (featureName: string, enabled: boolean): Promise<void> => {
47
- setActionError(null);
48
- const res = await dispatcher.write(FeatureToggleHandlers.set, { featureName, enabled });
49
- if (!res.isSuccess) {
50
- setActionError(res.error.message);
51
- return;
52
- }
53
- await load();
54
- };
55
-
56
- if (state.kind === "loading") {
57
- return (
58
- <FormScreenShell testId="toggle-admin-screen">
59
- <Text variant="small">{t("feature-toggles.admin.loading")}</Text>
60
- </FormScreenShell>
61
- );
62
- }
63
-
64
- if (state.kind === "error") {
65
- return (
66
- <FormScreenShell testId="toggle-admin-screen">
67
- <Banner variant="error">{state.message}</Banner>
68
- </FormScreenShell>
69
- );
70
- }
71
-
72
- return (
73
- <FormScreenShell testId="toggle-admin-screen" maxWidth="4xl" className="flex flex-col gap-6">
74
- <Heading variant="page">{t("feature-toggles.admin.title")}</Heading>
75
-
76
- {actionError !== null && <Banner variant="error">{actionError}</Banner>}
77
-
78
- <Card options={{ padded: false }}>
79
- <DataTable
80
- testId="toggle-admin-table"
81
- columns={[
82
- {
83
- field: "feature",
84
- label: t("feature-toggles.admin.col.feature"),
85
- type: "string",
86
- sortable: false,
87
- },
88
- {
89
- field: "default",
90
- label: t("feature-toggles.admin.col.default"),
91
- type: "string",
92
- sortable: false,
93
- },
94
- {
95
- field: "override",
96
- label: t("feature-toggles.admin.col.override"),
97
- type: "string",
98
- sortable: false,
99
- },
100
- {
101
- field: "effective",
102
- label: t("feature-toggles.admin.col.effective"),
103
- type: "string",
104
- sortable: false,
105
- },
106
- ]}
107
- rows={state.items.map((row) => ({
108
- id: row.name,
109
- values: {
110
- feature: row.name,
111
- default: formatFlag(row.default),
112
- override: formatFlag(row.override),
113
- effective: formatFlag(row.effective),
114
- toggleable: row.toggleable,
115
- effectiveBool: row.effective ?? row.default ?? false,
116
- },
117
- }))}
118
- rowActions={[
119
- {
120
- id: "toggle",
121
- label: t("feature-toggles.admin.toggle"),
122
- style: "secondary",
123
- isVisible: (row) => row.values["toggleable"] === true,
124
- onTrigger: (row) => void onSet(row.id, !(row.values["effectiveBool"] as boolean)),
125
- },
126
- ]}
127
- rowActionMode="inline"
128
- />
129
- </Card>
130
- </FormScreenShell>
131
- );
132
- }
133
-
134
- function formatFlag(value: boolean | null): string {
135
- if (value === null) return "inherit";
136
- return value ? "on" : "off";
137
- }