@cosmicdrift/kumiko-bundled-features 0.216.0 → 0.218.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.218.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.218.0",
130
+ "@cosmicdrift/kumiko-framework": "0.218.0",
131
+ "@cosmicdrift/kumiko-headless": "0.218.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.218.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.218.0",
134
+ "@cosmicdrift/kumiko-types": "0.218.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.218.0",
164
+ "@cosmicdrift/kumiko-locale-es": "0.218.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);
@@ -275,7 +275,7 @@ describe("POST /api/auth/signup-confirm", () => {
275
275
  expect(body.token).toBeTruthy();
276
276
  expect(body.user?.id).toBeTruthy();
277
277
  expect(body.user?.tenantId).toBeTruthy();
278
- expect(body.user?.roles).toContain("Admin");
278
+ expect(body.user?.roles).toContain("TenantAdmin");
279
279
  expect(body.tenantKey).toBeTruthy();
280
280
 
281
281
  // Cookies gesetzt (auth + csrf)
@@ -299,7 +299,7 @@ describe("POST /api/auth/signup-confirm", () => {
299
299
  expect(memberships).toHaveLength(1);
300
300
  const rolesRaw = memberships[0]?.["roles"];
301
301
  if (typeof rolesRaw === "string") {
302
- expect(JSON.parse(rolesRaw) as string[]).toContain("Admin");
302
+ expect(JSON.parse(rolesRaw) as string[]).toContain("TenantAdmin");
303
303
  }
304
304
 
305
305
  // #1463 regression: seedTenant fires the tenant entity's postSave
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.218.0",
4
+ "type": "improvement",
5
+ "title": "Self-signup provisions TenantAdmin on the new tenant",
6
+ "detail": "INITIAL_SIGNUP_ROLES is TenantAdmin (was Admin) so the first user on a new tenant can invite peers and manage memberships without elevation dead-ends."
7
+ },
2
8
  {
3
9
  "version": "0.165.1",
4
10
  "type": "breaking",
@@ -10,8 +16,8 @@
10
16
  "version": "0.167.1",
11
17
  "type": "improvement",
12
18
  "title": "accountLockout is reachable through runProdApp/runDevApp (fw#1627).",
13
- "detail": "accountLockout was an option on createAuthEmailPasswordFeature, but both wrappers mount auth-email-password themselves and their auth options only knew accountUnlock — an app on either entrypoint had no way to turn brute-force protection on. RunProdAppAuthOptions and RunDevAppAuthOptions now carry accountLockout and composeFeatures passes it through.",
14
- "migration": "Opt-in, nothing breaks without it. Two things to know before relying on it: without ctx.redis the lockout is silently skipped in login.write.ts — no error, no warning, no protection — so treat Redis as a deploy invariant, and prove the lockout with a behavioural test instead of assuming the mount is enough."
19
+ "detail": "accountLockout was an option on createAuthEmailPasswordFeature, but both wrappers mount auth-email-password themselves and their auth options only knew accountUnlock \u2014 an app on either entrypoint had no way to turn brute-force protection on. RunProdAppAuthOptions and RunDevAppAuthOptions now carry accountLockout and composeFeatures passes it through.",
20
+ "migration": "Opt-in, nothing breaks without it. Two things to know before relying on it: without ctx.redis the lockout is silently skipped in login.write.ts \u2014 no error, no warning, no protection \u2014 so treat Redis as a deploy invariant, and prove the lockout with a behavioural test instead of assuming the mount is enough."
15
21
  },
16
22
  {
17
23
  "version": "0.162.0",
@@ -22,19 +28,19 @@
22
28
  {
23
29
  "version": "0.154.1",
24
30
  "type": "fix",
25
- "title": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled…",
26
- "detail": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled-features/auth-email-password/web`. Every other auth screen (ForgotPasswordScreen, SignupScreen, ResetPasswordScreen, …) was already exported from the barrel with its props type; `LoginScreen` was missed. `makeAuthGate`'s second parameter is typed `LoginScreenProps`, so consumers passing a typed `loginScreenProps` override (e.g. `@cosmicdriftgamestudio/kumiko-designer`) couldn't import the type at all. - @cosmicdrift/kumiko-framework@0.154.1 - @cosmicdrift/kumiko-dispatcher-live@0.154.1 - @cosmicdrift/kumiko-headless@0.154.1 - @cosmicdrift/kumiko-renderer@0.154.1 - @cosmicdrift/kumiko-renderer-web@0.154.1"
31
+ "title": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled\u2026",
32
+ "detail": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled-features/auth-email-password/web`. Every other auth screen (ForgotPasswordScreen, SignupScreen, ResetPasswordScreen, \u2026) was already exported from the barrel with its props type; `LoginScreen` was missed. `makeAuthGate`'s second parameter is typed `LoginScreenProps`, so consumers passing a typed `loginScreenProps` override (e.g. `@cosmicdriftgamestudio/kumiko-designer`) couldn't import the type at all. - @cosmicdrift/kumiko-framework@0.154.1 - @cosmicdrift/kumiko-dispatcher-live@0.154.1 - @cosmicdrift/kumiko-headless@0.154.1 - @cosmicdrift/kumiko-renderer@0.154.1 - @cosmicdrift/kumiko-renderer-web@0.154.1"
27
33
  },
28
34
  {
29
35
  "version": "0.151.0",
30
36
  "type": "improvement",
31
- "title": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly — u…",
32
- "detail": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly — use the new `createLoginRoute({ loginScreenProps, mfaVerifyScreen, unaffected (they already build on the same logic internally now). Why: a raw `<LoginScreen />` render has no MFA-challenge handling unless the caller remembers to hand-wire `onMfaChallenge` + swap in a verify screen itself — exactly how kumiko-framework#266's login-time MFA step went missing in a real app's standalone apex/marketing login route (it renders outside `emailPasswordClient`'s own gate, which already handled this correctly). `createLoginRoute` is the one place this logic lives now, for both the gated and standalone cases — there's no lower-level piece left to misuse. The `apex-surface-auth` recipe is updated to match."
37
+ "title": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly \u2014 u\u2026",
38
+ "detail": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly \u2014 use the new `createLoginRoute({ loginScreenProps, mfaVerifyScreen, unaffected (they already build on the same logic internally now). Why: a raw `<LoginScreen />` render has no MFA-challenge handling unless the caller remembers to hand-wire `onMfaChallenge` + swap in a verify screen itself \u2014 exactly how kumiko-framework#266's login-time MFA step went missing in a real app's standalone apex/marketing login route (it renders outside `emailPasswordClient`'s own gate, which already handled this correctly). `createLoginRoute` is the one place this logic lives now, for both the gated and standalone cases \u2014 there's no lower-level piece left to misuse. The `apex-surface-auth` recipe is updated to match."
33
39
  },
34
40
  {
35
41
  "version": "0.131.0",
36
42
  "type": "improvement",
37
- "title": "App-Mounting 2.0 Säule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, Secti…",
38
- "detail": "App-Mounting 2.0 Säule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, SectionCard, StatusBadge, ProgressBar, CollapsibleSection, DetailList, ModeSwitch, StatusBarChart, TimeseriesChart, EmptyState/LoadingState/ErrorState, QueryTable) + Status-Farb-Tokens (--color-status-\\*). Neue Hooks useMutation + useDisclosure. Neues Core-Primitive Link (default/button/muted), Button-Variant \"link\", Text-Variant \"muted\"; auth-email-password nutzt sie (authButtonClass/authMutedLinkClass entfernt)."
43
+ "title": "App-Mounting 2.0 S\u00e4ule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, Secti\u2026",
44
+ "detail": "App-Mounting 2.0 S\u00e4ule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, SectionCard, StatusBadge, ProgressBar, CollapsibleSection, DetailList, ModeSwitch, StatusBarChart, TimeseriesChart, EmptyState/LoadingState/ErrorState, QueryTable) + Status-Farb-Tokens (--color-status-\\*). Neue Hooks useMutation + useDisclosure. Neues Core-Primitive Link (default/button/muted), Button-Variant \"link\", Text-Variant \"muted\"; auth-email-password nutzt sie (authButtonClass/authMutedLinkClass entfernt)."
39
45
  }
40
46
  ]
@@ -1,7 +1,7 @@
1
1
  // Magic-Link-Signup, Step 2 (confirm).
2
2
  //
3
3
  // Token aus URL + Password vom User → wir lösen den Token in Redis ein
4
- // und legen Tenant + User + Admin-Membership atomar an. emailVerified
4
+ // und legen Tenant + User + TenantAdmin membership atomically. emailVerified
5
5
  // wird sofort auf true gesetzt — der Magic-Link IST der Beweis.
6
6
  //
7
7
  // Pipeline:
@@ -141,7 +141,7 @@ export function createSignupConfirmHandler() {
141
141
 
142
142
  // SessionUser für JWT-Mint. Roles aus INITIAL_SIGNUP_ROLES
143
143
  // damit DB-write (provisionSignupAccount) und Session-claim
144
- // dieselbe Quelle teilen — sonst hätten zwei Stellen "Admin"
144
+ // dieselbe Quelle teilen — sonst hätten zwei Stellen "TenantAdmin"
145
145
  // hardcoded und ein Refactor würde role-mismatch zwischen DB
146
146
  // und JWT erzeugen.
147
147
  const session: SessionUser = {
@@ -103,12 +103,12 @@ export async function seedUserWithPassword(
103
103
  * minten (Account-Takeover, #365). `seedUser` selbst bleibt idempotent
104
104
  * add-only (für Bootstrap/Tests); die create-only-Garantie sitzt hier.
105
105
  * tenantKey-Kollisionen löst der Caller via generateUniqueName. */
106
- /** Default-Roles für den Self-Signup-Admin. Geteilt zwischen
106
+ /** Default membership roles for self-signup tenant owners. Shared between
107
107
  * provisionSignupAccount (DB-write) und signup-confirm-handler
108
108
  * (SessionUser-Konstruktion für JWT-Mint) — sonst hätten zwei
109
- * Stellen unabhängig "Admin" hardcoded und würden bei einem
109
+ * Stellen unabhängig "TenantAdmin" hardcoded und würden bei einem
110
110
  * Refactor zu role-mismatch zwischen DB und Session leiden. */
111
- export const INITIAL_SIGNUP_ROLES = ["Admin"] as const;
111
+ export const INITIAL_SIGNUP_ROLES = ["TenantAdmin"] as const;
112
112
 
113
113
  export type ProvisionSignupAccountOptions = {
114
114
  readonly email: string;
@@ -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,17 @@
1
1
  [
2
+ {
3
+ "version": "0.218.0",
4
+ "type": "fix",
5
+ "title": "user-list: system-scope search; membership roles on tenants; emailVerified sort",
6
+ "detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant → Meili miss/500). Tenants column shows membership roles (Offlot Demo (TenantAdmin)). emailVerified sortable; email also searchable for roster lookup. Global roles stay SystemAdmin-only — TenantAdmin/Admin live on membership (Team → Members)."
7
+ },
8
+ {
9
+ "version": "0.217.0",
10
+ "type": "improvement",
11
+ "title": "user-list: roles multiSelect + tenants column; roles editable as SystemAdmin (fw#2384 follow-up)",
12
+ "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.",
13
+ "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)."
14
+ },
2
15
  {
3
16
  "version": "0.167.1",
4
17
  "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,127 @@
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
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
+ // kumiko-lint-ignore cross-feature-import SystemAdmin user-list joins memberships for tenants column
10
+ import { tenantMembershipsTable, tenantTable } from "../../tenant";
2
11
  import { userEntity } from "../schema/user";
3
12
 
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, {
13
+ const baseList = defineEntityListHandler("user", userEntity, {
7
14
  access: { roles: access.systemAdmin },
8
15
  });
16
+
17
+ type MembershipRow = { userId: unknown; tenantId: unknown; roles?: unknown };
18
+ type TenantRow = { id: unknown; name?: unknown; key?: unknown };
19
+
20
+ function isMissingRelation(err: unknown): boolean {
21
+ const msg = err instanceof Error ? err.message : String(err);
22
+ return msg.includes("does not exist") || msg.includes("42P01");
23
+ }
24
+
25
+ async function loadMemberships(
26
+ db: TenantDb,
27
+ userIds: readonly string[],
28
+ ): Promise<readonly MembershipRow[] | null> {
29
+ try {
30
+ return await selectMany<MembershipRow>(db, tenantMembershipsTable, {
31
+ userId: { in: [...userIds] },
32
+ });
33
+ } catch (err) {
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
+ /** "Offlot Demo (TenantAdmin)" — membership roles live here, not on user.roles. */
50
+ function membershipLabel(tenantLabel: string, membershipRoles: readonly string[]): string {
51
+ if (membershipRoles.length === 0) return tenantLabel;
52
+ return `${tenantLabel} (${membershipRoles.join("+")})`;
53
+ }
54
+
55
+ function labelsByUserId(
56
+ memberships: readonly MembershipRow[],
57
+ labelByTenantId: Map<string, string>,
58
+ ): Map<string, string[]> {
59
+ const map = new Map<string, string[]>();
60
+ for (const m of memberships) {
61
+ const userId = String(m.userId ?? "");
62
+ const tenantId = String(m.tenantId ?? "");
63
+ if (userId === "" || tenantId === "") continue;
64
+ const tenantLabel = labelByTenantId.get(tenantId) ?? tenantId;
65
+ const label = membershipLabel(tenantLabel, parseRoles(m.roles ?? null));
66
+ const list = map.get(userId) ?? [];
67
+ list.push(label);
68
+ map.set(userId, list);
69
+ }
70
+ return map;
71
+ }
72
+
73
+ async function attachTenantLabels(
74
+ rows: readonly Record<string, unknown>[],
75
+ db: TenantDb,
76
+ ): Promise<Record<string, unknown>[]> {
77
+ if (rows.length === 0) return [];
78
+ const userIds = rows.map((r) => String(r["id"] ?? "")).filter((id) => id !== "");
79
+ if (userIds.length === 0) return [...rows];
80
+
81
+ const memberships = await loadMemberships(db, userIds);
82
+ if (memberships === null) {
83
+ return rows.map((row) => ({ ...row, tenants: "—" }));
84
+ }
85
+
86
+ const tenantIds = [
87
+ ...new Set(memberships.map((m) => String(m.tenantId ?? "")).filter((id) => id !== "")),
88
+ ];
89
+ const tenants =
90
+ tenantIds.length > 0
91
+ ? await selectMany<TenantRow>(db, tenantTable, { id: { in: tenantIds } })
92
+ : [];
93
+ const byUser = labelsByUserId(memberships, tenantLabelById(tenants));
94
+ return rows.map((row) => {
95
+ const labels = byUser.get(String(row["id"] ?? "")) ?? [];
96
+ return { ...row, tenants: labels.length > 0 ? labels.join(", ") : "—" };
97
+ });
98
+ }
99
+
100
+ function dbForList(ctx: HandlerContext): TenantDb {
101
+ if (!ctx.systemDb) {
102
+ throw new Error("user:list requires r.systemScope() / ctx.systemDb");
103
+ }
104
+ return ctx.systemDb.acknowledgeCrossTenant(
105
+ "user identity list is cross-tenant; tenants column joins memberships",
106
+ );
107
+ }
108
+
109
+ // System-wide user listing is SystemAdmin-only. Tenant admins list their
110
+ // members via the tenant feature (which scopes by membership, not globally).
111
+ // Wraps the entity-convention list so each row carries a `tenants` label
112
+ // (derived field) — without it the platform roster is unusable for ops.
113
+ export const listQuery = {
114
+ ...baseList,
115
+ handler: async (
116
+ query: Parameters<NonNullable<typeof baseList.handler>>[0],
117
+ ctx: HandlerContext,
118
+ ) => {
119
+ const result = await baseList.handler?.(query, ctx);
120
+ if (result === null || typeof result !== "object" || !("rows" in result)) {
121
+ return result;
122
+ }
123
+ const envelope = result as { rows: readonly Record<string, unknown>[] };
124
+ const rows = await attachTenantLabels(envelope.rows, dbForList(ctx));
125
+ return { ...envelope, rows };
126
+ },
127
+ };
@@ -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
@@ -5,7 +5,15 @@ export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
5
5
  "screen:user-edit.title": { en: "Edit user" },
6
6
  "user:entity:user:field:email": { en: "Email" },
7
7
  "user:entity:user:field:displayName": { en: "Display name" },
8
- "user:entity:user:field:roles": { en: "Roles" },
8
+ "user:entity:user:field:roles": { en: "Platform roles" },
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
+ "user:entity:user:field:roles:option:SystemAdmin": { en: "SystemAdmin" },
9
17
  "user:entity:user:field:status": { en: "Status" },
10
18
  "user:entity:user:field:emailVerified": { en: "Email verified" },
11
19
  "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
@@ -69,6 +72,8 @@ export const userEntity = createEntity({
69
72
  maxLength: 320,
70
73
  personal: "self",
71
74
  find: "exact",
75
+ // Roster search needs email hits; find:"exact" alone is blind-index only.
76
+ searchable: true,
72
77
  access: { write: access.privileged },
73
78
  }),
74
79
 
@@ -109,19 +114,17 @@ export const userEntity = createEntity({
109
114
  // refuse a session while this is false (strict mode).
110
115
  emailVerified: createBooleanField({
111
116
  default: false,
117
+ sortable: true,
112
118
  access: { write: access.privileged },
113
119
  }),
114
120
 
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: "[]",
121
+ // Global (cross-tenant) roles — parallel to tenantMemberships.roles.
122
+ // multiSelect stores jsonb string[]; login merges these with membership
123
+ // roles into the session. Write access privileged: users cannot self-elevate
124
+ // to SystemAdmin. Closed options = only assignable platform roles.
125
+ roles: createMultiSelectField({
126
+ options: GLOBAL_ROLE_OPTIONS,
127
+ default: [],
125
128
  access: { write: access.privileged },
126
129
  }),
127
130
 
@@ -179,6 +182,14 @@ export const userEntity = createEntity({
179
182
  // Global (not tenant-scoped): user is a tenant-agnostic identity
180
183
  // aggregate (systemStream: true) — a composite (tenantId, email) would
181
184
  // leave the same email resolvable twice.
185
+ // Filled by user:list / user:detail enrichment (membership → tenant names).
186
+ // derive() is a placeholder — list/detail handlers overwrite after the join.
187
+ derivedFields: {
188
+ tenants: createDerivedField({
189
+ valueType: "text",
190
+ derive: () => "",
191
+ }),
192
+ },
182
193
  indexes: [{ columns: ["email"], unique: true, name: "read_users_email_unique" }],
183
194
  });
184
195
 
@@ -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",
@@ -23,7 +27,8 @@ export const userListScreen: EntityListScreenDefinition = {
23
27
  },
24
28
  ],
25
29
  defaultSort: { field: "status", dir: "asc" },
26
- // ponytail: screen searchable but email is encrypted PII — list search uses non-encrypted columns only.
30
+ // Search uses SYSTEM_TENANT_ID for systemScope lists (see event-store-executor-read).
31
+ // Apps must boot a user search index on that tenant (offlot: bootWaitlistSearch).
27
32
  searchable: true,
28
33
  access: { roles: ["SystemAdmin"] },
29
34
  };
@@ -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,