@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 +9 -9
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +14 -2
- package/src/auth-email-password/web/auth-client.ts +5 -15
- package/src/user/__tests__/admin-screens.boot.test.ts +22 -0
- package/src/user/__tests__/user.integration.test.ts +33 -32
- package/src/user/changes.json +7 -0
- package/src/user/constants.ts +5 -0
- package/src/user/handlers/create.write.ts +5 -6
- package/src/user/handlers/list.query.ts +116 -4
- package/src/user/handlers/update-roles.ts +7 -4
- package/src/user/i18n.ts +2 -0
- package/src/user/schema/user.ts +18 -10
- package/src/user/screens.ts +5 -1
- package/src/user/seeding.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
164
|
-
"@cosmicdrift/kumiko-locale-es": "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:
|
|
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).
|
|
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
|
|
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 —
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
857
|
+
roles: ["SystemAdmin"],
|
|
857
858
|
});
|
|
858
859
|
const softDeletedAdmin = await seedUser({
|
|
859
860
|
email: "deleted-admin@example.com",
|
|
860
861
|
displayName: "DeletedAdmin",
|
|
861
|
-
roles:
|
|
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:
|
|
879
|
+
changes: { roles: ["User"] },
|
|
879
880
|
},
|
|
880
881
|
systemAdmin,
|
|
881
882
|
);
|
package/src/user/changes.json
CHANGED
|
@@ -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",
|
package/src/user/constants.ts
CHANGED
|
@@ -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 —
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
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" },
|
package/src/user/schema/user.ts
CHANGED
|
@@ -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
|
-
//
|
|
116
|
-
// string[];
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
package/src/user/screens.ts
CHANGED
|
@@ -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",
|
package/src/user/seeding.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type SeedUserOptions = {
|
|
|
33
33
|
* der argon2-Hashing übernimmt. */
|
|
34
34
|
readonly passwordHash?: string;
|
|
35
35
|
readonly locale?: string;
|
|
36
|
-
/**
|
|
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:
|
|
104
|
+
...(options.roles !== undefined && { roles: [...options.roles] }),
|
|
105
105
|
...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }),
|
|
106
106
|
},
|
|
107
107
|
by,
|