@cosmicdrift/kumiko-bundled-features 0.216.0 → 0.217.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.216.0",
3
+ "version": "0.217.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.216.0",
130
- "@cosmicdrift/kumiko-framework": "0.216.0",
131
- "@cosmicdrift/kumiko-headless": "0.216.0",
132
- "@cosmicdrift/kumiko-renderer": "0.216.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.216.0",
134
- "@cosmicdrift/kumiko-types": "0.216.0",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.217.0",
130
+ "@cosmicdrift/kumiko-framework": "0.217.0",
131
+ "@cosmicdrift/kumiko-headless": "0.217.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.217.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.217.0",
134
+ "@cosmicdrift/kumiko-types": "0.217.0",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,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.216.0",
164
- "@cosmicdrift/kumiko-locale-es": "0.216.0"
163
+ "@cosmicdrift/kumiko-locale-de": "0.217.0",
164
+ "@cosmicdrift/kumiko-locale-es": "0.217.0"
165
165
  }
166
166
  }
@@ -91,7 +91,7 @@ async function seedUser(
91
91
  displayName: email.split("@")[0] ?? "user",
92
92
  // user.roles im create wird privileged geprüft — systemAdmin hat
93
93
  // SystemAdmin-Rolle (siehe TestUsers.systemAdmin).
94
- roles: JSON.stringify(globalRoles),
94
+ roles: globalRoles,
95
95
  },
96
96
  systemAdmin,
97
97
  );
@@ -132,7 +132,7 @@ describe("multi-roles: login mergt globale + membership-roles", () => {
132
132
  // nur Zufall (z.B. wenn login-handler hardcoded SystemAdmin reinpacken
133
133
  // würde). Direct DB-read schließt das aus.
134
134
  const dbRow = await selectMany(stack.db, userTable, { id: userId });
135
- expect(dbRow[0]?.roles).toBe(JSON.stringify(["SystemAdmin"]));
135
+ expect(dbRow[0]?.roles).toEqual(["SystemAdmin"]);
136
136
 
137
137
  const { user } = await login("syadmin@example.com", "pw-long-enough");
138
138
  expect(user.tenantId).toBe(tenantA);
@@ -520,7 +520,7 @@ describe("fetchCurrentUser", () => {
520
520
  expect(res?.globalRoles).toEqual([]);
521
521
  });
522
522
 
523
- test("mixed-type array roles → empty globalRoles", async () => {
523
+ test("mixed-type JSON roles → keeps string entries only", async () => {
524
524
  globalThis.fetch = mock(async () =>
525
525
  jsonResponse({
526
526
  data: { id: "u1", email: "a@b.c", displayName: "User", roles: '["Admin",1,true]' },
@@ -529,7 +529,19 @@ describe("fetchCurrentUser", () => {
529
529
 
530
530
  const res = await fetchCurrentUser();
531
531
 
532
- expect(res?.globalRoles).toEqual([]);
532
+ expect(res?.globalRoles).toEqual(["Admin"]);
533
+ });
534
+
535
+ test("native array roles (jsonb wire) → globalRoles", async () => {
536
+ globalThis.fetch = mock(async () =>
537
+ jsonResponse({
538
+ data: { id: "u1", email: "a@b.c", displayName: "User", roles: ["SystemAdmin"] },
539
+ }),
540
+ ) as unknown as typeof fetch;
541
+
542
+ const res = await fetchCurrentUser();
543
+
544
+ expect(res?.globalRoles).toEqual(["SystemAdmin"]);
533
545
  });
534
546
 
535
547
  test("non-ok status → throws", async () => {
@@ -10,6 +10,7 @@
10
10
  // nicht divergieren.
11
11
 
12
12
  import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
13
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
13
14
 
14
15
  export type TenantSummary = {
15
16
  readonly tenantId: string;
@@ -414,19 +415,8 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
414
415
  };
415
416
  }
416
417
 
417
- // Defensive parse — server-side ist die Spalte JSON-encoded string[],
418
- // aber bei migration-drift oder corrupted-row liefern wir [] statt einen
419
- // runtime-throw der die ganze SessionProvider-mount blockt.
420
- function parseGlobalRoles(raw: string | undefined): readonly string[] {
421
- if (typeof raw !== "string" || raw.length === 0) return [];
422
- try {
423
- // @cast-boundary user-row.roles is JSON-encoded string[] per server contract
424
- const parsed = JSON.parse(raw) as unknown;
425
- if (Array.isArray(parsed) && parsed.every((r) => typeof r === "string")) {
426
- return parsed;
427
- }
428
- } catch {
429
- // malformed JSON → behave as empty
430
- }
431
- return [];
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);
432
422
  }
@@ -32,6 +32,28 @@ describe("user + tenant SystemAdmin admin screens", () => {
32
32
  expect(user.screens["user-edit"]?.type).toBe("entityEdit");
33
33
  });
34
34
 
35
+ test("user-edit layout includes roles (multiSelect) so SystemAdmin can promote peers", () => {
36
+ const user = createUserFeature();
37
+ const edit = user.screens["user-edit"];
38
+ expect(edit?.type).toBe("entityEdit");
39
+ if (edit?.type !== "entityEdit") throw new Error("expected entityEdit");
40
+ const fieldNames = edit.layout.sections.flatMap((s) =>
41
+ "fields" in s ? s.fields.map((f) => (typeof f === "string" ? f : f.field)) : [],
42
+ );
43
+ expect(fieldNames).toContain("roles");
44
+ expect(user.entities?.["user"]?.fields["roles"]?.type).toBe("multiSelect");
45
+ });
46
+
47
+ test("user-list columns include roles and tenants for the platform roster", () => {
48
+ const user = createUserFeature();
49
+ const list = user.screens["user-list"];
50
+ expect(list?.type).toBe("entityList");
51
+ if (list?.type !== "entityList") throw new Error("expected entityList");
52
+ const cols = list.columns.map((c) => (typeof c === "string" ? c : c.field));
53
+ expect(cols).toEqual(expect.arrayContaining(["roles", "tenants", "email"]));
54
+ expect(user.entities?.["user"]?.derivedFields?.["tenants"]).toBeDefined();
55
+ });
56
+
35
57
  test("user list/detail/create/update handlers already sit on the screen QNs", () => {
36
58
  const user = createUserFeature();
37
59
  // → user:query:user:list, user:query:user:detail
@@ -14,6 +14,7 @@ import {
14
14
  resetTestTables,
15
15
  updateRows,
16
16
  } from "@cosmicdrift/kumiko-framework/testing";
17
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
17
18
  import { UserErrors, UserHandlers, UserQueries } from "../constants";
18
19
  import { createUserFeature } from "../feature";
19
20
  import { userEntity, userTable } from "../schema/user";
@@ -41,7 +42,7 @@ async function seedUser(overrides: {
41
42
  email: string;
42
43
  displayName: string;
43
44
  passwordHash?: string;
44
- roles?: string;
45
+ roles?: readonly string[];
45
46
  }): Promise<{ id: string }> {
46
47
  const res = await stack.http.writeOk<{ id: string }>(
47
48
  UserHandlers.create,
@@ -390,7 +391,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
390
391
  {
391
392
  id: created.id,
392
393
  version: loaded["version"],
393
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
394
+ changes: { roles: ["SystemAdmin"] },
394
395
  },
395
396
  systemAdmin,
396
397
  );
@@ -400,7 +401,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
400
401
  { id: created.id },
401
402
  systemAdmin,
402
403
  );
403
- expect(JSON.parse(String(reloaded["roles"] ?? "[]"))).toEqual(["SystemAdmin"]);
404
+ expect(parseRoles(reloaded["roles"])).toEqual(["SystemAdmin"]);
404
405
  });
405
406
 
406
407
  test("promoted user with SystemAdmin role can perform SystemAdmin-only queries", async () => {
@@ -419,7 +420,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
419
420
  {
420
421
  id: created.id,
421
422
  version: loaded["version"],
422
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
423
+ changes: { roles: ["SystemAdmin"] },
423
424
  },
424
425
  systemAdmin,
425
426
  );
@@ -446,7 +447,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
446
447
  {
447
448
  id: created.id,
448
449
  version: loaded["version"],
449
- changes: { roles: JSON.stringify(["User", "Editor"]) },
450
+ changes: { roles: ["User", "Editor"] },
450
451
  },
451
452
  systemAdmin,
452
453
  );
@@ -456,7 +457,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
456
457
  { id: created.id },
457
458
  systemAdmin,
458
459
  );
459
- expect(JSON.parse(String(reloaded["roles"] ?? "[]"))).toEqual(["User", "Editor"]);
460
+ expect(parseRoles(reloaded["roles"])).toEqual(["User", "Editor"]);
460
461
  });
461
462
 
462
463
  test("TenantAdmin gets 403 on global role mutation (HTTP level check)", async () => {
@@ -473,7 +474,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
473
474
  {
474
475
  id: target.id,
475
476
  version: loaded["version"],
476
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
477
+ changes: { roles: ["SystemAdmin"] },
477
478
  },
478
479
  tenantAdmin,
479
480
  );
@@ -501,7 +502,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
501
502
  {
502
503
  id: tenantAdmin.id,
503
504
  version: loaded["version"],
504
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
505
+ changes: { roles: ["SystemAdmin"] },
505
506
  },
506
507
  actor,
507
508
  );
@@ -523,7 +524,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
523
524
  {
524
525
  id: user.id,
525
526
  version: loaded["version"],
526
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
527
+ changes: { roles: ["SystemAdmin"] },
527
528
  },
528
529
  normalUser,
529
530
  );
@@ -547,7 +548,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
547
548
  {
548
549
  id: target.id,
549
550
  version: loaded["version"],
550
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
551
+ changes: { roles: ["SystemAdmin"] },
551
552
  },
552
553
  attacker,
553
554
  );
@@ -570,7 +571,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
570
571
  {
571
572
  id: target.id,
572
573
  version: loaded["version"],
573
- changes: { roles: JSON.stringify(["SuperPowerUnknown"]) },
574
+ changes: { roles: ["SuperPowerUnknown"] },
574
575
  },
575
576
  systemAdmin,
576
577
  );
@@ -587,7 +588,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
587
588
  {
588
589
  email: "unknown-role-create@example.com",
589
590
  displayName: "BadRole",
590
- roles: JSON.stringify(["UnknownRole"]),
591
+ roles: ["UnknownRole"],
591
592
  },
592
593
  systemAdmin,
593
594
  );
@@ -604,7 +605,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
604
605
  const onlyAdmin = await seedUser({
605
606
  email: "only-admin@example.com",
606
607
  displayName: "OnlyAdmin",
607
- roles: JSON.stringify(["SystemAdmin"]),
608
+ roles: ["SystemAdmin"],
608
609
  });
609
610
  const loaded = await stack.http.queryOk<Record<string, unknown>>(
610
611
  UserQueries.detail,
@@ -618,7 +619,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
618
619
  {
619
620
  id: onlyAdmin.id,
620
621
  version: loaded["version"],
621
- changes: { roles: JSON.stringify(["User"]) },
622
+ changes: { roles: ["User"] },
622
623
  },
623
624
  systemAdmin,
624
625
  );
@@ -632,7 +633,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
632
633
  const secondAdmin = await seedUser({
633
634
  email: "second-admin@example.com",
634
635
  displayName: "SecondAdmin",
635
- roles: JSON.stringify(["SystemAdmin"]),
636
+ roles: ["SystemAdmin"],
636
637
  });
637
638
 
638
639
  // Demoting onlyAdmin now succeeds (since secondAdmin remains active)
@@ -641,7 +642,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
641
642
  {
642
643
  id: onlyAdmin.id,
643
644
  version: loaded["version"],
644
- changes: { roles: JSON.stringify(["User"]) },
645
+ changes: { roles: ["User"] },
645
646
  },
646
647
  systemAdmin,
647
648
  );
@@ -657,7 +658,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
657
658
  {
658
659
  id: secondAdmin.id,
659
660
  version: secondLoaded["version"],
660
- changes: { roles: JSON.stringify(["User"]) },
661
+ changes: { roles: ["User"] },
661
662
  },
662
663
  systemAdmin,
663
664
  );
@@ -674,7 +675,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
674
675
  const onlyAdmin = await seedUser({
675
676
  email: "empty-roles-admin@example.com",
676
677
  displayName: "EmptyRolesAdmin",
677
- roles: JSON.stringify(["SystemAdmin"]),
678
+ roles: ["SystemAdmin"],
678
679
  });
679
680
  const loaded = await stack.http.queryOk<Record<string, unknown>>(
680
681
  UserQueries.detail,
@@ -687,7 +688,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
687
688
  {
688
689
  id: onlyAdmin.id,
689
690
  version: loaded["version"],
690
- changes: { roles: JSON.stringify([]) },
691
+ changes: { roles: [] },
691
692
  },
692
693
  systemAdmin,
693
694
  );
@@ -702,7 +703,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
702
703
  const onlyAdmin = await seedUser({
703
704
  email: "non-role-admin@example.com",
704
705
  displayName: "InitialAdminName",
705
- roles: JSON.stringify(["SystemAdmin"]),
706
+ roles: ["SystemAdmin"],
706
707
  });
707
708
  const loaded = await stack.http.queryOk<Record<string, unknown>>(
708
709
  UserQueries.detail,
@@ -733,14 +734,14 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
733
734
  await seedUser({
734
735
  email: "the-system-admin@example.com",
735
736
  displayName: "TheAdmin",
736
- roles: JSON.stringify(["SystemAdmin"]),
737
+ roles: ["SystemAdmin"],
737
738
  });
738
739
 
739
740
  // Seed a normal user
740
741
  const normalUser = await seedUser({
741
742
  email: "regular-user@example.com",
742
743
  displayName: "RegularUser",
743
- roles: JSON.stringify(["User"]),
744
+ roles: ["User"],
744
745
  });
745
746
  const loaded = await stack.http.queryOk<Record<string, unknown>>(
746
747
  UserQueries.detail,
@@ -754,7 +755,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
754
755
  {
755
756
  id: normalUser.id,
756
757
  version: loaded["version"],
757
- changes: { roles: JSON.stringify(["User", "Editor"]) },
758
+ changes: { roles: ["User", "Editor"] },
758
759
  },
759
760
  systemAdmin,
760
761
  );
@@ -764,19 +765,19 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
764
765
  { id: normalUser.id },
765
766
  systemAdmin,
766
767
  );
767
- expect(JSON.parse(String(reloaded["roles"] ?? "[]"))).toEqual(["User", "Editor"]);
768
+ expect(parseRoles(reloaded["roles"])).toEqual(["User", "Editor"]);
768
769
  });
769
770
 
770
771
  test("concurrent demotions of the last two SystemAdmins: one wins, one gets 409", async () => {
771
772
  const adminA = await seedUser({
772
773
  email: "race-admin-a@example.com",
773
774
  displayName: "Race Admin A",
774
- roles: JSON.stringify(["SystemAdmin"]),
775
+ roles: ["SystemAdmin"],
775
776
  });
776
777
  const adminB = await seedUser({
777
778
  email: "race-admin-b@example.com",
778
779
  displayName: "Race Admin B",
779
- roles: JSON.stringify(["SystemAdmin"]),
780
+ roles: ["SystemAdmin"],
780
781
  });
781
782
 
782
783
  const loadedA = await stack.http.queryOk<Record<string, unknown>>(
@@ -796,7 +797,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
796
797
  {
797
798
  id: adminA.id,
798
799
  version: loadedA["version"],
799
- changes: { roles: JSON.stringify(["User"]) },
800
+ changes: { roles: ["User"] },
800
801
  },
801
802
  systemAdmin,
802
803
  ),
@@ -805,7 +806,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
805
806
  {
806
807
  id: adminB.id,
807
808
  version: loadedB["version"],
808
- changes: { roles: JSON.stringify(["User"]) },
809
+ changes: { roles: ["User"] },
809
810
  },
810
811
  systemAdmin,
811
812
  ),
@@ -843,7 +844,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
843
844
  { id },
844
845
  systemAdmin,
845
846
  );
846
- return JSON.parse(String(row["roles"] ?? "[]")).includes("SystemAdmin");
847
+ return parseRoles(row["roles"]).includes("SystemAdmin");
847
848
  }),
848
849
  );
849
850
  expect(stillAdmin.filter(Boolean)).toHaveLength(1);
@@ -853,12 +854,12 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
853
854
  const activeAdmin = await seedUser({
854
855
  email: "active-admin@example.com",
855
856
  displayName: "ActiveAdmin",
856
- roles: JSON.stringify(["SystemAdmin"]),
857
+ roles: ["SystemAdmin"],
857
858
  });
858
859
  const softDeletedAdmin = await seedUser({
859
860
  email: "deleted-admin@example.com",
860
861
  displayName: "DeletedAdmin",
861
- roles: JSON.stringify(["SystemAdmin"]),
862
+ roles: ["SystemAdmin"],
862
863
  });
863
864
 
864
865
  // Soft-delete the second admin directly
@@ -875,7 +876,7 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
875
876
  {
876
877
  id: activeAdmin.id,
877
878
  version: loaded["version"],
878
- changes: { roles: JSON.stringify(["User"]) },
879
+ changes: { roles: ["User"] },
879
880
  },
880
881
  systemAdmin,
881
882
  );
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.217.0",
4
+ "type": "improvement",
5
+ "title": "user-list: roles multiSelect + tenants column; roles editable as SystemAdmin (fw#2384 follow-up)",
6
+ "detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box — SystemAdmins can promote another SystemAdmin from the UI. user-list columns gain roles + tenants; tenants is a derived field filled by the list handler from memberships (platform roster was unusable without tenant context). switch-tenant + auth-client parseRoles accept jsonb arrays (string-only deserialize dropped SystemAdmin on tenant switch). Soft-deleted users stay hidden by default.",
7
+ "migration": "Run `kumiko-schema generate` and apply — migrates read_users.roles from text to jsonb USING roles::jsonb. Existing JSON array strings (`[]`, `[\"SystemAdmin\"]`) convert cleanly. Consumers that wrote roles as a JSON-encoded string via user:create/update should pass string[] instead (parseRoles still accepts both on read)."
8
+ },
2
9
  {
3
10
  "version": "0.167.1",
4
11
  "type": "breaking",
@@ -2,6 +2,11 @@
2
2
  // Feature name
3
3
  export const USER_FEATURE = "user" as const;
4
4
 
5
+ /** Assignable global (cross-tenant) roles on user.roles — SystemAdmin is the
6
+ * platform operator. Membership roles (TenantAdmin/Admin/User/…) live on
7
+ * tenant-membership, not here. */
8
+ export const GLOBAL_ROLE_OPTIONS = ["SystemAdmin"] as const;
9
+
5
10
  // Qualified write handler names. Handlers carry the "user:" entity prefix so
6
11
  // field-level access rules (passwordHash system-only etc.) are wired up.
7
12
  export const UserHandlers = {
@@ -39,11 +39,10 @@ export const createWrite = defineWriteHandler({
39
39
  displayName: z.string().min(1).max(100),
40
40
  locale: z.string().min(2).max(10).optional(),
41
41
  timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
42
- // Global roles — JSON-encoded string[]. Optional because the entity default
43
- // is "[]"; set when bootstrapping privileged global roles. Field-level
44
- // write access (privileged) is defense-in-depth — create is already
45
- // system/SystemAdmin-only.
46
- roles: z.string().optional(),
42
+ // Global roles — multiSelect jsonb string[]. Optional (entity default []).
43
+ // Field-level write access (privileged) is defense-in-depth — create is
44
+ // already system/SystemAdmin-only.
45
+ roles: z.union([z.string(), z.array(z.string())]).optional(),
47
46
  }),
48
47
  access: { roles: ["system", "SystemAdmin"] },
49
48
  handler: async (event, ctx) => {
@@ -81,7 +80,7 @@ export const createWrite = defineWriteHandler({
81
80
  }),
82
81
  );
83
82
  }
84
- createPayload = { ...event.payload, roles: JSON.stringify(newRoles) };
83
+ createPayload = { ...event.payload, roles: newRoles };
85
84
  }
86
85
 
87
86
  const result = await crud.create(createPayload, event.user, db);
@@ -1,8 +1,120 @@
1
- import { access, defineEntityListHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ access,
5
+ defineEntityListHandler,
6
+ type HandlerContext,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ // kumiko-lint-ignore cross-feature-import SystemAdmin user-list joins memberships for tenants column
9
+ import { tenantMembershipsTable, tenantTable } from "../../tenant";
2
10
  import { userEntity } from "../schema/user";
3
11
 
4
- // System-wide user listing is SystemAdmin-only. Tenant admins list their
5
- // members via the tenant feature (which scopes by membership, not globally).
6
- export const listQuery = defineEntityListHandler("user", userEntity, {
12
+ const baseList = defineEntityListHandler("user", userEntity, {
7
13
  access: { roles: access.systemAdmin },
8
14
  });
15
+
16
+ type MembershipRow = { userId: unknown; tenantId: unknown };
17
+ type TenantRow = { id: unknown; name?: unknown; key?: unknown };
18
+
19
+ function isMissingRelation(err: unknown): boolean {
20
+ const msg = err instanceof Error ? err.message : String(err);
21
+ return msg.includes("does not exist") || msg.includes("42P01");
22
+ }
23
+
24
+ async function loadMemberships(
25
+ db: TenantDb,
26
+ userIds: readonly string[],
27
+ ): Promise<readonly MembershipRow[] | null> {
28
+ try {
29
+ return await selectMany<MembershipRow>(db, tenantMembershipsTable, {
30
+ userId: { in: [...userIds] },
31
+ });
32
+ } catch (err) {
33
+ // User-only test stacks (and mid-migration DBs) may lack membership tables.
34
+ if (isMissingRelation(err)) return null;
35
+ throw err;
36
+ }
37
+ }
38
+
39
+ function tenantLabelById(tenants: readonly TenantRow[]): Map<string, string> {
40
+ const map = new Map<string, string>();
41
+ for (const t of tenants) {
42
+ const id = String(t.id ?? "");
43
+ if (id === "") continue;
44
+ map.set(id, String(t.name ?? t.key ?? id));
45
+ }
46
+ return map;
47
+ }
48
+
49
+ function labelsByUserId(
50
+ memberships: readonly MembershipRow[],
51
+ labelByTenantId: Map<string, string>,
52
+ ): Map<string, string[]> {
53
+ const map = new Map<string, string[]>();
54
+ for (const m of memberships) {
55
+ const userId = String(m.userId ?? "");
56
+ const tenantId = String(m.tenantId ?? "");
57
+ if (userId === "" || tenantId === "") continue;
58
+ const label = labelByTenantId.get(tenantId) ?? tenantId;
59
+ const list = map.get(userId) ?? [];
60
+ list.push(label);
61
+ map.set(userId, list);
62
+ }
63
+ return map;
64
+ }
65
+
66
+ async function attachTenantLabels(
67
+ rows: readonly Record<string, unknown>[],
68
+ db: TenantDb,
69
+ ): Promise<Record<string, unknown>[]> {
70
+ if (rows.length === 0) return [];
71
+ const userIds = rows.map((r) => String(r["id"] ?? "")).filter((id) => id !== "");
72
+ if (userIds.length === 0) return [...rows];
73
+
74
+ const memberships = await loadMemberships(db, userIds);
75
+ if (memberships === null) {
76
+ return rows.map((row) => ({ ...row, tenants: "—" }));
77
+ }
78
+
79
+ const tenantIds = [
80
+ ...new Set(memberships.map((m) => String(m.tenantId ?? "")).filter((id) => id !== "")),
81
+ ];
82
+ const tenants =
83
+ tenantIds.length > 0
84
+ ? await selectMany<TenantRow>(db, tenantTable, { id: { in: tenantIds } })
85
+ : [];
86
+ const byUser = labelsByUserId(memberships, tenantLabelById(tenants));
87
+ return rows.map((row) => {
88
+ const labels = byUser.get(String(row["id"] ?? "")) ?? [];
89
+ return { ...row, tenants: labels.length > 0 ? labels.join(", ") : "—" };
90
+ });
91
+ }
92
+
93
+ function dbForList(ctx: HandlerContext): TenantDb {
94
+ if (!ctx.systemDb) {
95
+ throw new Error("user:list requires r.systemScope() / ctx.systemDb");
96
+ }
97
+ return ctx.systemDb.acknowledgeCrossTenant(
98
+ "user identity list is cross-tenant; tenants column joins memberships",
99
+ );
100
+ }
101
+
102
+ // System-wide user listing is SystemAdmin-only. Tenant admins list their
103
+ // members via the tenant feature (which scopes by membership, not globally).
104
+ // Wraps the entity-convention list so each row carries a `tenants` label
105
+ // (derived field) — without it the platform roster is unusable for ops.
106
+ export const listQuery = {
107
+ ...baseList,
108
+ handler: async (
109
+ query: Parameters<NonNullable<typeof baseList.handler>>[0],
110
+ ctx: HandlerContext,
111
+ ) => {
112
+ const result = await baseList.handler?.(query, ctx);
113
+ if (result === null || typeof result !== "object" || !("rows" in result)) {
114
+ return result;
115
+ }
116
+ const envelope = result as { rows: readonly Record<string, unknown>[] };
117
+ const rows = await attachTenantLabels(envelope.rows, dbForList(ctx));
118
+ return { ...envelope, rows };
119
+ },
120
+ };
@@ -52,14 +52,16 @@ function isActiveSystemAdminRow(row: UserRolesRow): boolean {
52
52
 
53
53
  async function countOtherActiveSystemAdmins(db: TenantDb, excludeUserId: string): Promise<number> {
54
54
  const tableName = asEntityTableMeta(userTable)?.tableName ?? "read_users";
55
- // kumiko-lint-ignore raw-sql LIKE prefilter for SystemAdmin roster under advisory lock
55
+ // kumiko-lint-ignore raw-sql jsonb @> prefilter for SystemAdmin roster under advisory lock
56
56
  const rows = (await asRawClient(db.raw).unsafe(
57
57
  `SELECT id, roles, status, is_deleted AS "isDeleted"
58
58
  FROM ${quoteIdent(tableName)}
59
59
  WHERE is_deleted = false
60
60
  AND (status = $1 OR status IS NULL)
61
- AND roles LIKE '%SystemAdmin%'`,
62
- [USER_STATUS.Active],
61
+ AND roles @> $2::jsonb`,
62
+ // Pass a JS array — JSON.stringify + $n::jsonb double-encodes to a jsonb string
63
+ // (postgres.js), so `@>` never matches a roles array.
64
+ [USER_STATUS.Active, ["SystemAdmin"]],
63
65
  )) as UserRolesRow[];
64
66
  return rows.filter((u) => u.id !== excludeUserId && isActiveSystemAdminRow(u)).length;
65
67
  }
@@ -138,7 +140,8 @@ export async function applyUserRolesUpdate(
138
140
 
139
141
  const normalizedChanges = {
140
142
  ...event.payload.changes,
141
- roles: JSON.stringify(newRoles),
143
+ // multiSelect column is jsonb string[] — do not JSON.stringify.
144
+ roles: newRoles,
142
145
  };
143
146
 
144
147
  // Persist roles first. Session revoke runs only after a successful update so a
package/src/user/i18n.ts CHANGED
@@ -6,6 +6,8 @@ export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
6
6
  "user:entity:user:field:email": { en: "Email" },
7
7
  "user:entity:user:field:displayName": { en: "Display name" },
8
8
  "user:entity:user:field:roles": { en: "Roles" },
9
+ "user:entity:user:field:tenants": { en: "Tenants" },
10
+ "user:entity:user:field:roles:option:SystemAdmin": { en: "SystemAdmin" },
9
11
  "user:entity:user:field:status": { en: "Status" },
10
12
  "user:entity:user:field:emailVerified": { en: "Email verified" },
11
13
  "user:entity:user:field:locale": { en: "Locale" },
@@ -2,12 +2,15 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  access,
4
4
  createBooleanField,
5
+ createDerivedField,
5
6
  createEntity,
7
+ createMultiSelectField,
6
8
  createSelectField,
7
9
  createTextField,
8
10
  createTimestampField,
9
11
  createTzField,
10
12
  } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { GLOBAL_ROLE_OPTIONS } from "../constants";
11
14
 
12
15
  /**
13
16
  * User-Lifecycle-Status (S2.U1). Single source of truth — Auth-Middleware
@@ -112,16 +115,13 @@ export const userEntity = createEntity({
112
115
  access: { write: access.privileged },
113
116
  }),
114
117
 
115
- // Globale Rollen — parallel zu tenantMemberships.roles. JSON-encoded
116
- // string[]; parseRoles() deserialisiert beim Read. Login-Handler mergt
117
- // diese Rollen mit den tenant-membership-roles in die Session — so
118
- // sind sie tenant-unabhängig (z.B. SystemAdmin, BillingAdmin). Default
119
- // "[]" damit die Session-Roles-Merge keinen NULL-Branch braucht.
120
- // Schreibrecht privileged: ein User darf sich nicht selbst zum
121
- // SystemAdmin machen.
122
- roles: createTextField({
123
- required: true,
124
- default: "[]",
118
+ // Global (cross-tenant) roles — parallel to tenantMemberships.roles.
119
+ // multiSelect stores jsonb string[]; login merges these with membership
120
+ // roles into the session. Write access privileged: users cannot self-elevate
121
+ // to SystemAdmin. Closed options = only assignable platform roles.
122
+ roles: createMultiSelectField({
123
+ options: GLOBAL_ROLE_OPTIONS,
124
+ default: [],
125
125
  access: { write: access.privileged },
126
126
  }),
127
127
 
@@ -179,6 +179,14 @@ export const userEntity = createEntity({
179
179
  // Global (not tenant-scoped): user is a tenant-agnostic identity
180
180
  // aggregate (systemStream: true) — a composite (tenantId, email) would
181
181
  // leave the same email resolvable twice.
182
+ // Filled by user:list / user:detail enrichment (membership → tenant names).
183
+ // derive() is a placeholder — list/detail handlers overwrite after the join.
184
+ derivedFields: {
185
+ tenants: createDerivedField({
186
+ valueType: "text",
187
+ derive: () => "",
188
+ }),
189
+ },
182
190
  indexes: [{ columns: ["email"], unique: true, name: "read_users_email_unique" }],
183
191
  });
184
192
 
@@ -7,12 +7,16 @@ import type {
7
7
  // feature runs with `r.systemScope()`, the entityList query returns every
8
8
  // user across all tenants — the SystemAdmin platform roster. Both screens are
9
9
  // SystemAdmin-gated and stay inert until an app navs them (no auto-nav).
10
+ //
11
+ // `roles` is a multiSelect (global SystemAdmin). `tenants` is a derived field
12
+ // filled by the list handler from memberships — without it the roster is
13
+ // unusable for ops (waitlist signups look like platform users with no context).
10
14
 
11
15
  export const userListScreen: EntityListScreenDefinition = {
12
16
  id: "user-list",
13
17
  type: "entityList",
14
18
  entity: "user",
15
- columns: ["email", "displayName", "status", "emailVerified"],
19
+ columns: ["email", "displayName", "roles", "tenants", "status", "emailVerified"],
16
20
  rowActions: [
17
21
  {
18
22
  kind: "navigate",
@@ -33,7 +33,7 @@ export type SeedUserOptions = {
33
33
  * der argon2-Hashing übernimmt. */
34
34
  readonly passwordHash?: string;
35
35
  readonly locale?: string;
36
- /** Globale Rollen — landen in users.roles als JSON-encoded string[].
36
+ /** Global roles — land in users.roles as jsonb string[].
37
37
  * Werden vom login-handler in die Session-Roles parallel zu tenant-
38
38
  * membership-roles gemerged. Default: leer-array. Typisches Beispiel:
39
39
  * `["SystemAdmin"]` für den Plattform-Operator. */
@@ -101,7 +101,7 @@ export async function seedUser(
101
101
  displayName: options.displayName,
102
102
  ...(options.passwordHash !== undefined && { passwordHash: options.passwordHash }),
103
103
  ...(options.locale !== undefined && { locale: options.locale }),
104
- ...(options.roles !== undefined && { roles: JSON.stringify(options.roles) }),
104
+ ...(options.roles !== undefined && { roles: [...options.roles] }),
105
105
  ...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }),
106
106
  },
107
107
  by,