@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.220.1

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 (37) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/web/auth-client.ts +1 -7
  3. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
  4. package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
  5. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
  6. package/src/feature-toggles/feature.ts +24 -4
  7. package/src/feature-toggles/handlers/registered.query.ts +27 -16
  8. package/src/feature-toggles/i18n.ts +5 -0
  9. package/src/feature-toggles/web/client-plugin.tsx +1 -5
  10. package/src/feature-toggles/web/index.ts +0 -1
  11. package/src/file-derivatives/feature.ts +1 -5
  12. package/src/secrets/handlers/delete.write.ts +0 -4
  13. package/src/secrets/handlers/list.query.ts +0 -4
  14. package/src/secrets/handlers/set.write.ts +0 -4
  15. package/src/secrets/index.ts +3 -3
  16. package/src/tags/__tests__/feature.test.ts +18 -5
  17. package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
  18. package/src/tags/constants.ts +3 -2
  19. package/src/tags/entity.ts +2 -0
  20. package/src/tags/feature.ts +16 -10
  21. package/src/tags/handlers/create-tag.write.ts +5 -2
  22. package/src/tags/handlers/delete-tag.write.ts +5 -2
  23. package/src/tags/handlers/update-tag.write.ts +5 -2
  24. package/src/tags/i18n.ts +4 -0
  25. package/src/tags/index.ts +2 -0
  26. package/src/tags/screens.ts +66 -0
  27. package/src/tags/web/client-plugin.tsx +1 -6
  28. package/src/tags/web/index.ts +1 -0
  29. package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
  30. package/src/tenant/__tests__/tenant-security.integration.test.ts +34 -0
  31. package/src/tenant/handlers/team-list.query.ts +3 -1
  32. package/src/tenant/handlers/update-member-roles.write.ts +15 -1
  33. package/src/tenant/i18n.ts +0 -1
  34. package/src/tenant/screens.ts +3 -2
  35. package/src/user/handlers/update-roles.ts +5 -1
  36. package/src/user/i18n.ts +0 -6
  37. package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.220.0",
3
+ "version": "0.220.1",
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.220.0",
130
- "@cosmicdrift/kumiko-framework": "0.220.0",
131
- "@cosmicdrift/kumiko-headless": "0.220.0",
132
- "@cosmicdrift/kumiko-renderer": "0.220.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.220.0",
134
- "@cosmicdrift/kumiko-types": "0.220.0",
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",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,7 +160,7 @@
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.220.0",
164
- "@cosmicdrift/kumiko-locale-es": "0.220.0"
163
+ "@cosmicdrift/kumiko-locale-de": "0.220.1",
164
+ "@cosmicdrift/kumiko-locale-es": "0.220.1"
165
165
  }
166
166
  }
@@ -411,12 +411,6 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
411
411
  email: body.data.email,
412
412
  displayName: body.data.displayName,
413
413
  ...(body.data.locale !== undefined && { locale: body.data.locale }),
414
- globalRoles: parseGlobalRoles(body.data.roles),
414
+ globalRoles: parseRoles(body.data.roles),
415
415
  };
416
416
  }
417
-
418
- // Defensive parse — roles may be jsonb string[] (0.217+) or a legacy
419
- // JSON-encoded string; malformed → [] so SessionProvider mount never throws.
420
- function parseGlobalRoles(raw: unknown): readonly string[] {
421
- return parseRoles(raw);
422
- }
@@ -568,7 +568,7 @@ describe("feature-toggles queries + audit automation", () => {
568
568
  test("registered query reports metadata + override + effective for every feature", async () => {
569
569
  runtime.apply("widget", false);
570
570
  const data = await stack.http.queryOk<{
571
- items: Array<{
571
+ rows: Array<{
572
572
  name: string;
573
573
  toggleable: boolean;
574
574
  default: boolean | null;
@@ -577,7 +577,7 @@ describe("feature-toggles queries + audit automation", () => {
577
577
  effective: boolean | null;
578
578
  }>;
579
579
  }>("feature-toggles:query:registered", {}, admin);
580
- const byName = new Map(data.items.map((i) => [i.name, i]));
580
+ const byName = new Map(data.rows.map((i) => [i.name, i]));
581
581
 
582
582
  expect(byName.get("widget")).toMatchObject({
583
583
  toggleable: true,
@@ -11,11 +11,19 @@ describe("feature-toggles screen + handler access alignment", () => {
11
11
  expect(() => validateBoot(features)).not.toThrow();
12
12
  });
13
13
 
14
- test("toggle-admin screen is SystemAdmin-gated", () => {
14
+ test("toggle-admin screen is projectionList and SystemAdmin-gated", () => {
15
15
  const toggles = createFeatureTogglesFeature();
16
16
  const screen = toggles.screens[TOGGLE_ADMIN_SCREEN_ID];
17
- expect(screen?.type).toBe("custom");
18
- if (screen && "access" in screen && screen.access && "roles" in screen.access) {
17
+ expect(screen?.type).toBe("projectionList");
18
+ if (screen?.type !== "projectionList") throw new Error("expected projectionList");
19
+ expect(screen.query).toBe(FeatureToggleQueries.registered);
20
+ const toggleAction = screen.rowActions?.find((a) => a.id === "toggle");
21
+ if (toggleAction?.kind !== "writeHandler") throw new Error("expected a writeHandler rowAction");
22
+ expect(toggleAction.handler).toBe(FeatureToggleHandlers.set);
23
+ expect(toggleAction.payload).toEqual({
24
+ map: { featureName: "name", enabled: "nextEnabled" },
25
+ });
26
+ if ("access" in screen && screen.access && "roles" in screen.access) {
19
27
  expect(screen.access.roles).toEqual(["SystemAdmin"]);
20
28
  }
21
29
  });
@@ -36,6 +36,7 @@ describe("feature-toggles access matrix", () => {
36
36
  ]);
37
37
  const feature = createFeatureTogglesFeature();
38
38
  const screen = feature.screens[TOGGLE_ADMIN_SCREEN_ID];
39
+ expect(screen?.type).toBe("projectionList");
39
40
  if (screen && "access" in screen && screen.access && "roles" in screen.access) {
40
41
  expect(screen.access.roles).toEqual(["SystemAdmin"]);
41
42
  }
@@ -49,12 +50,12 @@ describe("feature-toggles HTTP access", () => {
49
50
  { items?: readonly unknown[] } | { rows?: readonly unknown[] }
50
51
  >(FeatureToggleQueries.list, {}, admin);
51
52
  expect(typeof list).toBe("object");
52
- const registered = await stack.http.queryOk<{ items: readonly unknown[] }>(
53
+ const registered = await stack.http.queryOk<{ rows: readonly unknown[] }>(
53
54
  FeatureToggleQueries.registered,
54
55
  {},
55
56
  admin,
56
57
  );
57
- expect(Array.isArray(registered.items)).toBe(true);
58
+ expect(Array.isArray(registered.rows)).toBe(true);
58
59
  });
59
60
 
60
61
  test("TenantAdmin gets 403 on list, registered, and set", async () => {
@@ -1,5 +1,10 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { FEATURE_TOGGLE_SET_EVENT_NAME, TOGGLE_ADMIN_SCREEN_ID } from "./constants";
2
+ import {
3
+ FEATURE_TOGGLE_SET_EVENT_NAME,
4
+ FeatureToggleHandlers,
5
+ FeatureToggleQueries,
6
+ TOGGLE_ADMIN_SCREEN_ID,
7
+ } from "./constants";
3
8
  import { featureToggleSetSchema } from "./events";
4
9
  import { globalFeatureStateTableMeta } from "./global-feature-state-table";
5
10
  import { listQuery } from "./handlers/list.query";
@@ -67,11 +72,26 @@ export function createFeatureTogglesFeature(
67
72
  registered: r.queryHandler(registeredQuery),
68
73
  };
69
74
 
70
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
71
75
  r.screen({
72
76
  id: TOGGLE_ADMIN_SCREEN_ID,
73
- type: "custom",
74
- renderer: { react: { __component: "ToggleAdminScreen" } },
77
+ type: "projectionList",
78
+ query: FeatureToggleQueries.registered,
79
+ columns: [
80
+ { field: "name", label: "feature-toggles.admin.col.feature" },
81
+ { field: "defaultLabel", label: "feature-toggles.admin.col.default" },
82
+ { field: "overrideLabel", label: "feature-toggles.admin.col.override" },
83
+ { field: "effectiveLabel", label: "feature-toggles.admin.col.effective" },
84
+ ],
85
+ rowActions: [
86
+ {
87
+ kind: "writeHandler",
88
+ id: "toggle",
89
+ label: "feature-toggles.admin.toggle",
90
+ handler: FeatureToggleHandlers.set,
91
+ payload: { map: { featureName: "name", enabled: "nextEnabled" } },
92
+ visible: { field: "toggleable", eq: true },
93
+ },
94
+ ],
75
95
  access: { roles: ["SystemAdmin"] },
76
96
  });
77
97
  r.nav({
@@ -12,6 +12,12 @@ import { globalFeatureStateTable } from "../global-feature-state-table";
12
12
  // Design: registry introspection (toggleable/default/requires) + a single
13
13
  // DB read of overrides. No per-feature DB calls. Scales to however many
14
14
  // features an app registers — currently tens, never thousands.
15
+
16
+ function formatFlag(value: boolean | null): string {
17
+ if (value === null) return "inherit";
18
+ return value ? "on" : "off";
19
+ }
20
+
15
21
  export const registeredQuery = defineQueryHandler({
16
22
  name: "registered",
17
23
  schema: z.object({}),
@@ -38,38 +44,43 @@ export const registeredQuery = defineQueryHandler({
38
44
  // dokumentiert in DispatcherOptions.effectiveFeatures.
39
45
  const effective = ctx.effectiveFeatures?.(SYSTEM_TENANT_ID);
40
46
 
41
- const items: Array<{
47
+ const rows: Array<{
48
+ id: string;
42
49
  name: string;
43
50
  toggleable: boolean;
44
51
  default: boolean | null;
45
52
  override: boolean | null;
46
53
  requires: readonly string[];
47
54
  effective: boolean | null;
55
+ defaultLabel: string;
56
+ overrideLabel: string;
57
+ effectiveLabel: string;
58
+ nextEnabled: boolean;
48
59
  }> = [];
60
+
49
61
  for (const feature of ctx.registry.features.values()) {
50
62
  const toggleable = feature.toggleableDefault !== undefined;
51
63
  const override = overrides.get(feature.name);
52
- items.push({
64
+ const defaultValue = feature.toggleableDefault ?? null;
65
+ const effectiveValue = effective ? effective.has(feature.name) : null;
66
+ const effectiveBool = effectiveValue ?? defaultValue ?? false;
67
+
68
+ rows.push({
69
+ id: feature.name,
53
70
  name: feature.name,
54
71
  toggleable,
55
- // `default` is null when non-toggleable; the UI must render
56
- // non-toggleable features as "always on" without an enable/disable
57
- // control (flipping them would be rejected by the set-handler).
58
- default: feature.toggleableDefault ?? null,
59
- // `override` is null when no explicit row exists. That's distinct
60
- // from "override says on" or "override says off" so the UI can show
61
- // an "inherits default" indicator.
72
+ // Raw booleans for API consumers; label fields for projectionList columns.
73
+ default: defaultValue,
62
74
  override: override ?? null,
63
75
  requires: feature.requires,
64
- // Effective = what the dispatcher-gate actually uses right now,
65
- // after cascade. When the feature-toggles runtime isn't wired
66
- // (dev setup without the feature loaded), we surface null so the
67
- // UI knows the runtime isn't available rather than defaulting
68
- // to "everything on".
69
- effective: effective ? effective.has(feature.name) : null,
76
+ effective: effectiveValue,
77
+ defaultLabel: formatFlag(defaultValue),
78
+ overrideLabel: formatFlag(override ?? null),
79
+ effectiveLabel: formatFlag(effectiveValue),
80
+ nextEnabled: !effectiveBool,
70
81
  });
71
82
  }
72
83
 
73
- return { items };
84
+ return { rows, nextCursor: null };
74
85
  },
75
86
  });
@@ -3,4 +3,9 @@ type LocalizedString = { readonly en: string };
3
3
  export const FEATURE_TOGGLES_I18N: Readonly<Record<string, LocalizedString>> = {
4
4
  "screen:toggle-admin.title": { en: "Feature toggles" },
5
5
  "feature-toggles:nav.toggleAdmin": { en: "Feature toggles" },
6
+ "feature-toggles.admin.col.feature": { en: "Feature" },
7
+ "feature-toggles.admin.col.default": { en: "Default" },
8
+ "feature-toggles.admin.col.override": { en: "Override" },
9
+ "feature-toggles.admin.col.effective": { en: "Effective" },
10
+ "feature-toggles.admin.toggle": { en: "Toggle" },
6
11
  };
@@ -1,9 +1,8 @@
1
1
  // @runtime client
2
2
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
3
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
- import { FEATURE_TOGGLES_FEATURE, TOGGLE_ADMIN_SCREEN_ID } from "../constants";
4
+ import { FEATURE_TOGGLES_FEATURE } from "../constants";
5
5
  import { defaultTranslations } from "./i18n";
6
- import { ToggleAdminScreen } from "./toggle-admin-screen";
7
6
 
8
7
  export type FeatureTogglesClientOptions = {
9
8
  readonly translations?: TranslationsByLocale;
@@ -15,8 +14,5 @@ export function featureTogglesClient(
15
14
  return {
16
15
  name: FEATURE_TOGGLES_FEATURE,
17
16
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
18
- components: {
19
- [TOGGLE_ADMIN_SCREEN_ID]: ToggleAdminScreen,
20
- },
21
17
  };
22
18
  }
@@ -3,4 +3,3 @@ export {
3
3
  type FeatureTogglesClientOptions,
4
4
  featureTogglesClient,
5
5
  } from "./client-plugin";
6
- export { ToggleAdminScreen } from "./toggle-admin-screen";
@@ -112,12 +112,10 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
112
112
  // dispatch even when the httpRoute below isn't mounted, bypassing the
113
113
  // "tenantId comes from the host, never the payload" invariant that only
114
114
  // `resolveApexTenant` enforces.
115
- const queries: { publicVariant?: ReturnType<typeof r.queryHandler> } = {};
116
-
117
115
  if (opts.resolveApexTenant) {
118
116
  const resolveApexTenant = opts.resolveApexTenant;
119
117
 
120
- queries.publicVariant = r.queryHandler(publicVariantQuery);
118
+ r.queryHandler(publicVariantQuery);
121
119
 
122
120
  r.httpRoute({
123
121
  method: "GET",
@@ -180,8 +178,6 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
180
178
  },
181
179
  });
182
180
  }
183
-
184
- return { queries };
185
181
  });
186
182
  }
187
183
 
@@ -21,7 +21,3 @@ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS)
21
21
  },
22
22
  });
23
23
  }
24
-
25
- // Backwards-compat: existing imports of `deleteWrite` keep working —
26
- // identical to createDeleteHandler() with the default access.
27
- export const deleteWrite = createDeleteHandler();
@@ -38,7 +38,3 @@ export function createListHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
38
38
  },
39
39
  });
40
40
  }
41
-
42
- // Backwards-compat: existing imports of `listQuery` keep working — identical
43
- // to createListHandler() with the default access.
44
- export const listQuery = createListHandler();
@@ -44,10 +44,6 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
44
44
  });
45
45
  }
46
46
 
47
- // Backwards-compat: existing imports of `setWrite` keep working — identical
48
- // to createSetHandler() with the default access.
49
- export const setWrite = createSetHandler();
50
-
51
47
  // Fallback redaction: show at most the first 3 chars + trailing bullets.
52
48
  // Deliberately conservative — a too-generous preview defeats the point.
53
49
  function defaultRedact(value: string): string {
@@ -13,11 +13,11 @@ export {
13
13
  TENANT_SECRET_READ_EVENT,
14
14
  tenantSecretsTable,
15
15
  } from "./feature";
16
- export { createDeleteHandler, deleteWrite } from "./handlers/delete.write";
17
- export { createListHandler, listQuery } from "./handlers/list.query";
16
+ export { createDeleteHandler } from "./handlers/delete.write";
17
+ export { createListHandler } from "./handlers/list.query";
18
18
  export {
19
19
  type RotateJobPayload,
20
20
  type RotateJobResult,
21
21
  rotateJob,
22
22
  } from "./handlers/rotate.job";
23
- export { createSetHandler, setWrite } from "./handlers/set.write";
23
+ export { createSetHandler } from "./handlers/set.write";
@@ -48,7 +48,7 @@ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch
48
48
  }
49
49
 
50
50
  describe("createTagsFeature shape", () => {
51
- test("registers tag + tag-assignment entities, 5 write-handlers, 2 query-handlers", () => {
51
+ test("registers tag + tag-assignment entities, legacy + convention CRUD handlers", () => {
52
52
  const feature = createTagsFeature();
53
53
 
54
54
  expect(Object.keys(feature.entities ?? {})).toEqual(
@@ -62,17 +62,21 @@ describe("createTagsFeature shape", () => {
62
62
  expect.stringMatching(/delete-tag/),
63
63
  expect.stringMatching(/assign-tag/),
64
64
  expect.stringMatching(/remove-tag/),
65
+ "tag:create",
66
+ "tag:update",
67
+ "tag:delete",
65
68
  ]),
66
69
  );
67
- expect(Object.keys(feature.writeHandlers)).toHaveLength(5);
70
+ expect(Object.keys(feature.writeHandlers)).toHaveLength(8);
68
71
 
69
72
  expect(Object.keys(feature.queryHandlers)).toEqual(
70
73
  expect.arrayContaining([
71
74
  expect.stringMatching(/tag:list/),
72
75
  expect.stringMatching(/tag-assignment:list/),
76
+ "tag:detail",
73
77
  ]),
74
78
  );
75
- expect(Object.keys(feature.queryHandlers)).toHaveLength(2);
79
+ expect(Object.keys(feature.queryHandlers)).toHaveLength(3);
76
80
  });
77
81
  });
78
82
 
@@ -102,10 +106,19 @@ describe("createTagsFeature access-options", () => {
102
106
 
103
107
  test("access:{openToAll} applies to every write- and query-path", () => {
104
108
  const feature = createTagsFeature({ access: { openToAll: true } });
105
- for (const path of ["create-tag", "update-tag", "delete-tag", "assign-tag", "remove-tag"]) {
109
+ for (const path of [
110
+ "create-tag",
111
+ "update-tag",
112
+ "delete-tag",
113
+ "tag:create",
114
+ "tag:update",
115
+ "tag:delete",
116
+ "assign-tag",
117
+ "remove-tag",
118
+ ]) {
106
119
  expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
107
120
  }
108
- for (const query of ["tag:list", "tag-assignment:list"]) {
121
+ for (const query of ["tag:list", "tag:detail", "tag-assignment:list"]) {
109
122
  expect(rawQueryAccess(feature, query)).toEqual({ openToAll: true });
110
123
  }
111
124
  });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { TAGS_EDIT_SCREEN_ID, TAGS_SCREEN_ID } from "../constants";
4
+ import { createTagsFeature } from "../feature";
5
+
6
+ // QN convention: entityList loads tags:query:tag:list; entityEdit loads
7
+ // tags:query:tag:detail and saves via tags:write:tag:{create,update,delete}.
8
+ // Legacy create-tag/update-tag/delete-tag stay registered for TagManager.
9
+
10
+ describe("tags catalog screens (entityList + entityEdit)", () => {
11
+ const feature = createTagsFeature();
12
+
13
+ test("boot-validates with declarative tag-list + tag-edit", () => {
14
+ expect(() => validateBoot([feature])).not.toThrow();
15
+ });
16
+
17
+ test("tag-list is entityList gated by default tag roles", () => {
18
+ const list = feature.screens[TAGS_SCREEN_ID];
19
+ expect(list?.type).toBe("entityList");
20
+ expect(list?.access).toEqual({ roles: ["TenantAdmin", "TenantMember"] });
21
+ });
22
+
23
+ test("tag-edit is entityEdit with matching access", () => {
24
+ const edit = feature.screens[TAGS_EDIT_SCREEN_ID];
25
+ expect(edit?.type).toBe("entityEdit");
26
+ expect(edit?.access).toEqual({ roles: ["TenantAdmin", "TenantMember"] });
27
+ });
28
+
29
+ test("convention handlers sit beside legacy create-tag/update-tag/delete-tag", () => {
30
+ expect(Object.keys(feature.writeHandlers)).toEqual(
31
+ expect.arrayContaining([
32
+ "create-tag",
33
+ "update-tag",
34
+ "delete-tag",
35
+ "tag:create",
36
+ "tag:update",
37
+ "tag:delete",
38
+ ]),
39
+ );
40
+ expect(Object.keys(feature.queryHandlers)).toEqual(
41
+ expect.arrayContaining(["tag:list", "tag:detail", "tag-assignment:list"]),
42
+ );
43
+ });
44
+
45
+ test("createTagsFeature({ roles }) threads access onto both screens", () => {
46
+ const custom = createTagsFeature({ roles: ["Admin"] });
47
+ expect(custom.screens[TAGS_SCREEN_ID]?.access).toEqual({ roles: ["Admin"] });
48
+ expect(custom.screens[TAGS_EDIT_SCREEN_ID]?.access).toEqual({ roles: ["Admin"] });
49
+ expect(custom.writeHandlers["tag:delete"]?.access).toEqual({ roles: ["Admin"] });
50
+ });
51
+ });
@@ -25,9 +25,10 @@ export const TAGS_FILTER_EXTENSION_NAME = "TagFilter";
25
25
  // after mounting tagsClient() (which registers it under clientFeatures.columnRenderers).
26
26
  export const TAGS_COLUMN_RENDERER_NAME = "TagsCell";
27
27
 
28
- // Screen-id of the standalone Tags management screen (custom screen rendering
29
- // TagManager). Qualified = "tags:screen:tag-list"; the app places it via r.nav.
28
+ // Screen-ids for the declarative tag catalog (entityList + entityEdit).
29
+ // Qualified list = "tags:screen:tag-list"; the app places it via r.nav.
30
30
  export const TAGS_SCREEN_ID = "tag-list";
31
+ export const TAGS_EDIT_SCREEN_ID = "tag-edit";
31
32
 
32
33
  // Qualified handler names (QN format: scope:type:name). Clients reference the
33
34
  // object instead of magic strings (mirror custom-fields' Handlers/Queries).
@@ -15,6 +15,8 @@ export const tagEntity = createEntity({
15
15
  name: createTextField({
16
16
  required: true,
17
17
  maxLength: 64,
18
+ sortable: true,
19
+ searchable: true,
18
20
  personal: false,
19
21
  reason: "catalog_label",
20
22
  }),
@@ -14,15 +14,18 @@
14
14
  //
15
15
  // Handlers: create-tag, update-tag (rename/recolor/re-scope), delete-tag
16
16
  // (cascades over assignments), assign-tag, remove-tag, list tags, list assignments.
17
+ // Convention aliases tag:{create,update,delete} + tag:detail back the
18
+ // entityList/entityEdit catalog screens (legacy QNs stay for TagManager).
17
19
  // Deferred: optional host-projection decoration (`wireTagsFor`), search indexing.
18
20
 
19
21
  import {
20
22
  type AccessRule,
23
+ defineEntityDetailHandler,
21
24
  defineEntityListHandler,
22
25
  defineFeature,
23
26
  type FeatureRegistrar,
24
27
  } from "@cosmicdrift/kumiko-framework/engine";
25
- import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME, TAGS_SCREEN_ID } from "./constants";
28
+ import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants";
26
29
  import { tagAssignmentEntity, tagEntity } from "./entity";
27
30
  import { createAssignTagHandler } from "./handlers/assign-tag.write";
28
31
  import { createCreateTagHandler } from "./handlers/create-tag.write";
@@ -30,6 +33,7 @@ import { createDeleteTagHandler } from "./handlers/delete-tag.write";
30
33
  import { createRemoveTagHandler } from "./handlers/remove-tag.write";
31
34
  import { createUpdateTagHandler } from "./handlers/update-tag.write";
32
35
  import { TAGS_FEATURE_I18N } from "./i18n";
36
+ import { createTagEditScreen, createTagListScreen } from "./screens";
33
37
 
34
38
  // Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
35
39
  // dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
@@ -66,18 +70,20 @@ function registerTags(
66
70
  r.writeHandler(createAssignTagHandler(access));
67
71
  r.writeHandler(createRemoveTagHandler(access));
68
72
 
73
+ // Convention aliases for entityList/entityEdit — same bodies as the legacy
74
+ // create-tag/update-tag/delete-tag QNs (TagManager/TagPicker keep those).
75
+ r.writeHandler(createCreateTagHandler(access, "tag:create"));
76
+ r.writeHandler(createUpdateTagHandler(access, "tag:update"));
77
+ r.writeHandler(createDeleteTagHandler(access, "tag:delete"));
78
+
69
79
  r.queryHandler(defineEntityListHandler("tag", tagEntity, { access }));
70
80
  r.queryHandler(defineEntityListHandler("tag-assignment", tagAssignmentEntity, { access }));
81
+ r.queryHandler(defineEntityDetailHandler("tag", tagEntity, { access }));
71
82
 
72
- // Standalone Tags management screen (custom React: TagManager). The app places
73
- // it in nav via r.nav("tags:screen:tag-list"); tagsClient() maps the component.
74
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
75
- r.screen({
76
- id: TAGS_SCREEN_ID,
77
- type: "custom",
78
- renderer: { react: { __component: "TagsScreen" } },
79
- access,
80
- });
83
+ // Standalone catalog: entityList + entityEdit. App navs via
84
+ // r.nav("tags:screen:tag-list"). TagManager stays for picker/section only.
85
+ r.screen(createTagListScreen(access));
86
+ r.screen(createTagEditScreen(access));
81
87
  r.translations({ keys: TAGS_FEATURE_I18N });
82
88
  }
83
89
 
@@ -7,9 +7,12 @@ import { type CreateTagPayload, createTagPayloadSchema } from "../schemas";
7
7
  // UUIDv7 id (no explicit id passed). Tag names are not unique by design: the
8
8
  // catalog is a free list and dedup is a UI concern (autocomplete from existing
9
9
  // tags). Editing is update-tag.write.ts; delete (with cascade) is delete-tag.write.ts.
10
- export function createCreateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
10
+ export function createCreateTagHandler(
11
+ access: AccessRule = DEFAULT_TAG_ACCESS,
12
+ name = "create-tag",
13
+ ): WriteHandlerDef {
11
14
  return {
12
- name: "create-tag",
15
+ name,
13
16
  schema: createTagPayloadSchema,
14
17
  access,
15
18
  handler: async (event, ctx) => {
@@ -16,9 +16,12 @@ const CASCADE_PAGE = 200;
16
16
  // hides isDeleted rows, so a keyset cursor over the shrinking result set would
17
17
  // silently skip rows. Re-reading the head always returns the remaining live
18
18
  // rows and terminates when none are left.
19
- export function createDeleteTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
19
+ export function createDeleteTagHandler(
20
+ access: AccessRule = DEFAULT_TAG_ACCESS,
21
+ name = "delete-tag",
22
+ ): WriteHandlerDef {
20
23
  return {
21
- name: "delete-tag",
24
+ name,
22
25
  schema: deleteTagPayloadSchema,
23
26
  access,
24
27
  handler: async (event, ctx) => {
@@ -8,9 +8,12 @@ import { type UpdateTagPayload, updateTagPayloadSchema } from "../schemas";
8
8
  // the fields present in the payload go into `changes`, and the executor merges
9
9
  // shallowly, so any omitted field is preserved (color/scope accept "" to clear).
10
10
  // A stale version returns version_conflict; the UI refetches and retries.
11
- export function createUpdateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
11
+ export function createUpdateTagHandler(
12
+ access: AccessRule = DEFAULT_TAG_ACCESS,
13
+ name = "update-tag",
14
+ ): WriteHandlerDef {
12
15
  return {
13
- name: "update-tag",
16
+ name,
14
17
  schema: updateTagPayloadSchema,
15
18
  access,
16
19
  handler: async (event, ctx) => {
package/src/tags/i18n.ts CHANGED
@@ -2,4 +2,8 @@ type LocalizedString = { readonly en: string };
2
2
 
3
3
  export const TAGS_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
4
4
  "screen:tag-list.title": { en: "Tags" },
5
+ "screen:tag-edit.title": { en: "Tag" },
6
+ "tags:entity:tag:field:name": { en: "Name" },
7
+ "tags:entity:tag:field:color": { en: "Color" },
8
+ "tags:entity:tag:field:scope": { en: "Scope" },
5
9
  };
package/src/tags/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  DEFAULT_TAG_ACCESS,
4
4
  DEFAULT_TAG_ROLES,
5
5
  TAGS_COLUMN_RENDERER_NAME,
6
+ TAGS_EDIT_SCREEN_ID,
6
7
  TAGS_FEATURE_NAME,
7
8
  TAGS_FILTER_EXTENSION_NAME,
8
9
  TAGS_SCREEN_ID,
@@ -44,3 +45,4 @@ export {
44
45
  type UpdateTagPayload,
45
46
  updateTagPayloadSchema,
46
47
  } from "./schemas";
48
+ export { createTagEditScreen, createTagListScreen } from "./screens";
@@ -0,0 +1,66 @@
1
+ import type {
2
+ AccessRule,
3
+ EntityEditScreenDefinition,
4
+ EntityListScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { DEFAULT_TAG_ACCESS, TAGS_EDIT_SCREEN_ID, TAGS_SCREEN_ID } from "./constants";
7
+
8
+ // Declarative catalog screens (Phase-3 / #2312). Access matches create/update/
9
+ // delete so createTagsFeature({ access|roles }) stays consistent. TagManager
10
+ // remains for TagPicker/TagSection — only the standalone admin screen moved.
11
+
12
+ export function createTagListScreen(
13
+ access: AccessRule = DEFAULT_TAG_ACCESS,
14
+ ): EntityListScreenDefinition {
15
+ return {
16
+ id: TAGS_SCREEN_ID,
17
+ type: "entityList",
18
+ entity: "tag",
19
+ columns: ["name", "color", "scope"],
20
+ defaultSort: { field: "name", dir: "asc" },
21
+ searchable: true,
22
+ rowActions: [
23
+ {
24
+ kind: "navigate",
25
+ id: "edit",
26
+ label: "kumiko.actions.edit",
27
+ screen: TAGS_EDIT_SCREEN_ID,
28
+ entityId: "id",
29
+ },
30
+ {
31
+ kind: "writeHandler",
32
+ id: "delete",
33
+ label: "kumiko.actions.delete",
34
+ // Convention QN — same cascade body as legacy tags:write:delete-tag.
35
+ handler: "tags:write:tag:delete",
36
+ payload: { pick: ["id"] },
37
+ confirm: "kumiko.actions.delete-confirm",
38
+ style: "danger",
39
+ },
40
+ ],
41
+ access,
42
+ };
43
+ }
44
+
45
+ export function createTagEditScreen(
46
+ access: AccessRule = DEFAULT_TAG_ACCESS,
47
+ ): EntityEditScreenDefinition {
48
+ return {
49
+ id: TAGS_EDIT_SCREEN_ID,
50
+ type: "entityEdit",
51
+ entity: "tag",
52
+ layout: {
53
+ sections: [
54
+ {
55
+ columns: 2,
56
+ fields: [
57
+ { field: "name", span: 2 },
58
+ { field: "color", span: 1 },
59
+ { field: "scope", span: 1 },
60
+ ],
61
+ },
62
+ ],
63
+ },
64
+ access,
65
+ };
66
+ }
@@ -5,12 +5,10 @@ import {
5
5
  TAGS_COLUMN_RENDERER_NAME,
6
6
  TAGS_FEATURE_NAME,
7
7
  TAGS_FILTER_EXTENSION_NAME,
8
- TAGS_SCREEN_ID,
9
8
  TAGS_SECTION_EXTENSION_NAME,
10
9
  } from "../constants";
11
10
  import { defaultTranslations } from "./i18n";
12
11
  import { TagFilter } from "./tag-filter";
13
- import { TagManager } from "./tag-manager";
14
12
  import { TagSection } from "./tag-section";
15
13
  import { TagsCell } from "./tags-cell";
16
14
 
@@ -26,10 +24,7 @@ export function tagsClient(): ClientFeatureDefinition {
26
24
  columnRenderers: {
27
25
  [TAGS_COLUMN_RENDERER_NAME]: TagsCell,
28
26
  },
29
- // Standalone Tags management screen (custom screen → TagManager).
30
- components: {
31
- [TAGS_SCREEN_ID]: TagManager,
32
- },
27
+ // Catalog screen is declarative entityList — no custom TagsScreen mount.
33
28
  translations: defaultTranslations,
34
29
  };
35
30
  }
@@ -1,6 +1,7 @@
1
1
  // @runtime client
2
2
  export {
3
3
  TAGS_COLUMN_RENDERER_NAME,
4
+ TAGS_EDIT_SCREEN_ID,
4
5
  TAGS_FILTER_EXTENSION_NAME,
5
6
  TAGS_SCREEN_ID,
6
7
  TAGS_SECTION_EXTENSION_NAME,
@@ -74,7 +74,7 @@ describe("tenant members screen + handler access alignment", () => {
74
74
  const rowAction = screen.rowActions?.find((a) => a.id === "edit-roles");
75
75
  if (rowAction?.kind !== "navigate") throw new Error("expected a navigate row action");
76
76
  expect(rowAction.screen).toBe(MEMBER_ROLES_EDIT_SCREEN_ID);
77
- expect(rowAction.params).toEqual({ map: { userId: "userId" } });
77
+ expect(rowAction.params).toEqual({ map: { userId: "userId", roles: "roles" } });
78
78
  expect(rowAction.visible).toEqual({ field: "status", eq: "active" });
79
79
  });
80
80
 
@@ -85,6 +85,11 @@ describe("tenant members screen + handler access alignment", () => {
85
85
  throw new Error("expected member-roles-edit screen to be actionForm");
86
86
  expect(screen.handler).toBe(TenantHandlers.updateMemberRoles);
87
87
  expect(screen.fields["userId"]).toEqual({ type: "text", required: true });
88
+ const section = screen.layout.sections[0];
89
+ if (section === undefined || !("fields" in section)) {
90
+ throw new Error("expected member-roles-edit layout fields section");
91
+ }
92
+ expect(section.fields).toEqual([{ field: "userId", readOnly: true }, "roles"]);
88
93
  expect(screen.fields["roles"]).toEqual({
89
94
  type: "multiSelect",
90
95
  options: DEFAULT_INVITE_ROLE_OPTIONS,
@@ -859,6 +859,40 @@ describe("updateMemberRoles — TenantAdmin session-scoped path and safety gates
859
859
  expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
860
860
  });
861
861
 
862
+ test("SystemAdmin omits payload.tenantId — uses session tenant (actionForm UI shape)", async () => {
863
+ const { id: memberUserId } = await seedUser(stack.db, {
864
+ email: "sysadmin-session-tenant@example.com",
865
+ displayName: "SysAdmin Session Tenant",
866
+ passwordHash: await hashPassword("pw-sys-sess-1234"),
867
+ emailVerified: true,
868
+ });
869
+ await seedTenantMembership(stack.db, {
870
+ userId: memberUserId,
871
+ tenantId: TENANT_A_ID,
872
+ roles: ["User"],
873
+ });
874
+
875
+ // Mirrors prod: SystemAdmin with an active tenant opens Edit roles; the
876
+ // actionForm only posts userId+roles (no tenantId field).
877
+ const sysAdminInTenant: SessionUser = {
878
+ id: TestUsers.systemAdmin.id,
879
+ tenantId: TENANT_A_ID,
880
+ roles: ["SystemAdmin", "TenantAdmin"],
881
+ };
882
+ const res = await stack.http.writeOk(
883
+ TenantHandlers.updateMemberRoles,
884
+ { userId: memberUserId, roles: ["Admin"] },
885
+ sysAdminInTenant,
886
+ );
887
+ expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
888
+
889
+ const rows = await selectMany(stack.db, tenantMembershipsTable, {
890
+ userId: memberUserId,
891
+ tenantId: TENANT_A_ID,
892
+ });
893
+ expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
894
+ });
895
+
862
896
  test("updateMemberRoles appends audit event and invalidates active sessions for target user", async () => {
863
897
  const { id: memberUserId } = await seedUser(stack.db, {
864
898
  email: "session-target@example.com",
@@ -223,7 +223,9 @@ export const teamListQuery = definePagedQueryHandler({
223
223
  // the order of a few thousand members per tenant; past that the fix is
224
224
  // a real combined read-projection (a materialized view/table kept in
225
225
  // sync by both tenant-membership and tenant-invitation events), not a
226
- // cursor bolted onto this merge.
226
+ // cursor bolted onto this merge. Upgrade path: r.multiStreamProjection({
227
+ // name, apply }) — see custom-fields/wire-for-entity.ts:174,
228
+ // feature-toggles/feature.ts:109, step-dispatcher/feature.ts:41.
227
229
  const search = query.payload.search?.trim().toLowerCase();
228
230
  const merged = [...memberRows, ...invitationRows].filter((row) => {
229
231
  if (search === undefined || search === "") return true;
@@ -41,6 +41,16 @@ const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user"
41
41
  // (dispatcher batch wraps handlers in transaction — xact lock holds through update).
42
42
  const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 0x7461646d; // 'tadm'
43
43
 
44
+ /** SystemAdmin may pass payload.tenantId (cross-tenant); the members actionForm
45
+ * only sends userId+roles, so fall back to the active session tenant. */
46
+ function resolveTargetTenantId(
47
+ isSystem: boolean,
48
+ payloadTenantId: string | undefined,
49
+ sessionTenantId: string | undefined,
50
+ ): string | undefined {
51
+ return isSystem ? (payloadTenantId ?? sessionTenantId) : sessionTenantId;
52
+ }
53
+
44
54
  export const updateMemberRolesWrite = defineWriteHandler({
45
55
  name: "updateMemberRoles",
46
56
  schema: z.object({
@@ -63,7 +73,11 @@ export const updateMemberRolesWrite = defineWriteHandler({
63
73
 
64
74
  const isSystem =
65
75
  event.user.roles.includes("SystemAdmin") || event.user.roles.includes("system");
66
- const targetTenantId = isSystem ? event.payload.tenantId : event.user.tenantId;
76
+ const targetTenantId = resolveTargetTenantId(
77
+ isSystem,
78
+ event.payload.tenantId,
79
+ event.user.tenantId,
80
+ );
67
81
  if (!targetTenantId) {
68
82
  return writeFailure(
69
83
  new ValidationError({
@@ -22,7 +22,6 @@ export const TENANT_I18N: Readonly<Record<string, LocalizedString>> = {
22
22
  },
23
23
  "tenant.members.invite.title": { en: "New invitation" },
24
24
  "tenant.members.invite.submit": { en: "Send invitation" },
25
- "tenant.members.roles.edit.title": { en: "Edit roles" },
26
25
  "tenant.members.roles.edit.submit": { en: "Save roles" },
27
26
  "tenant:entity:__action-form__:field:email": { en: "Email" },
28
27
  "tenant:entity:__action-form__:field:role": { en: "Role" },
@@ -113,7 +113,7 @@ export function createMembersScreen(options?: {
113
113
  id: "edit-roles",
114
114
  label: "tenant.members.actions.editRoles",
115
115
  screen: MEMBER_ROLES_EDIT_SCREEN_ID,
116
- params: { map: { userId: "userId" } },
116
+ params: { map: { userId: "userId", roles: "roles" } },
117
117
  visible: { field: "status", eq: "active" },
118
118
  },
119
119
  {
@@ -172,7 +172,8 @@ export const memberRolesEditScreen = {
172
172
  roles: { type: "multiSelect", options: DEFAULT_INVITE_ROLE_OPTIONS, required: true },
173
173
  },
174
174
  layout: {
175
- sections: [{ fields: ["userId", "roles"] }],
175
+ // Prefill userId from rowAction; readOnly so the operator cannot retarget.
176
+ sections: [{ fields: [{ field: "userId", readOnly: true }, "roles"] }],
176
177
  },
177
178
  submitLabel: "tenant.members.roles.edit.submit",
178
179
  access: { roles: access.admin },
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  AccessDeniedError,
17
17
  ConflictError,
18
+ InternalError,
18
19
  NotFoundError,
19
20
  writeFailure,
20
21
  } from "@cosmicdrift/kumiko-framework/errors";
@@ -51,7 +52,10 @@ function isActiveSystemAdminRow(row: UserRolesRow): boolean {
51
52
  }
52
53
 
53
54
  async function countOtherActiveSystemAdmins(db: TenantDb, excludeUserId: string): Promise<number> {
54
- const tableName = asEntityTableMeta(userTable)?.tableName ?? "read_users";
55
+ const tableName = asEntityTableMeta(userTable)?.tableName;
56
+ if (!tableName) {
57
+ throw new InternalError({ message: "user read table meta missing" });
58
+ }
55
59
  // kumiko-lint-ignore raw-sql jsonb @> prefilter for SystemAdmin roster under advisory lock
56
60
  const rows = (await asRawClient(db.raw).unsafe(
57
61
  `SELECT id, roles, status, is_deleted AS "isDeleted"
package/src/user/i18n.ts CHANGED
@@ -7,12 +7,6 @@ export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
7
7
  "user:entity:user:field:displayName": { en: "Display name" },
8
8
  "user:entity:user:field:roles": { en: "Platform roles" },
9
9
  "user:entity:user:field:tenants": { en: "Tenants" },
10
- "user:entity:user:field:tenants:description": {
11
- en: "Membership tenants with roles, e.g. Offlot Demo (TenantAdmin). Edit under Team → Members.",
12
- },
13
- "user:entity:user:field:roles:description": {
14
- en: "Global platform roles only (SystemAdmin). TenantAdmin/Admin are membership roles under Team → Members.",
15
- },
16
10
  "user:entity:user:field:roles:option:SystemAdmin": { en: "SystemAdmin" },
17
11
  "user:entity:user:field:status": { en: "Status" },
18
12
  "user:entity:user:field:emailVerified": { en: "Email verified" },
@@ -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
- }