@cosmicdrift/kumiko-bundled-features 0.215.7 → 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.
Files changed (33) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +35 -2
  3. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +86 -37
  4. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  5. package/src/auth-email-password/handlers/invite-create.write.ts +12 -3
  6. package/src/auth-email-password/web/__tests__/auth-client.test.ts +14 -2
  7. package/src/auth-email-password/web/auth-client.ts +5 -15
  8. package/src/shared/map-with-concurrency.test.ts +1 -1
  9. package/src/tenant/__tests__/members-screens.boot.test.ts +39 -2
  10. package/src/tenant/__tests__/seed-testing.integration.test.ts +11 -9
  11. package/src/tenant/__tests__/tenant-security.integration.test.ts +433 -5
  12. package/src/tenant/__tests__/tenant.integration.test.ts +3 -2
  13. package/src/tenant/constants.ts +3 -0
  14. package/src/tenant/feature.ts +4 -3
  15. package/src/tenant/handlers/team-list.query.ts +3 -0
  16. package/src/tenant/handlers/update-member-roles.write.ts +107 -20
  17. package/src/tenant/i18n.ts +10 -1
  18. package/src/tenant/membership-roles.ts +5 -2
  19. package/src/tenant/screens.ts +24 -0
  20. package/src/tenant/seeding.ts +9 -6
  21. package/src/user/__tests__/admin-screens.boot.test.ts +22 -0
  22. package/src/user/__tests__/user.integration.test.ts +561 -3
  23. package/src/user/changes.json +7 -0
  24. package/src/user/constants.ts +8 -0
  25. package/src/user/handlers/create.write.ts +36 -18
  26. package/src/user/handlers/list.query.ts +116 -4
  27. package/src/user/handlers/update-roles.ts +172 -0
  28. package/src/user/handlers/update.write.ts +32 -8
  29. package/src/user/i18n.ts +3 -0
  30. package/src/user/schema/user.ts +18 -10
  31. package/src/user/screens.ts +6 -2
  32. package/src/user/seeding.ts +2 -2
  33. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +43 -3
@@ -5,9 +5,11 @@
5
5
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
6
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
7
  import { access, type SessionUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
8
9
  import {
9
10
  setupTestStack,
10
11
  type TestStack,
12
+ TestUsers,
11
13
  unsafeCreateEntityTable,
12
14
  unsafePushTables,
13
15
  } from "@cosmicdrift/kumiko-framework/stack";
@@ -15,6 +17,7 @@ import { expectErrorIncludes, rolesOf, seedRow } from "@cosmicdrift/kumiko-frame
15
17
  import { Temporal } from "temporal-polyfill";
16
18
  import { AuthHandlers } from "../../auth-email-password/constants";
17
19
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
20
+ import { authFoundationFeature } from "../../auth-foundation";
18
21
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
19
22
  import { createConfigFeature } from "../../config";
20
23
  import { createConfigResolver } from "../../config/resolver";
@@ -23,13 +26,18 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
23
26
  import { notificationPreferencesTable } from "../../delivery/tables";
24
27
  import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
25
28
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
26
- import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
29
+ import { createSessionsFeature, userSessionEntity, userSessionTable } from "../../sessions";
27
30
  import { hashPassword } from "../../shared";
28
31
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
29
32
  import { createUserFeature } from "../../user/feature";
30
33
  import { userEntity, userTable } from "../../user/schema/user";
31
34
  import { seedUser } from "../../user/seeding";
32
- import { MEMBERS_SCREEN_ID, TenantHandlers, TenantQueries } from "../constants";
35
+ import {
36
+ MEMBER_ROLES_EDIT_SCREEN_ID,
37
+ MEMBERS_SCREEN_ID,
38
+ TenantHandlers,
39
+ TenantQueries,
40
+ } from "../constants";
33
41
  import { createTenantFeature } from "../feature";
34
42
  import { tenantInvitationEntity, tenantInvitationsTable } from "../invitation-table";
35
43
  import { tenantMembershipsTable } from "../membership-table";
@@ -66,6 +74,10 @@ beforeAll(async () => {
66
74
  // inviteScreen: this suite dispatches AuthHandlers.inviteCreate and
67
75
  // needs /members' invite-create actionForm screen registered too.
68
76
  createTenantFeature({ inviteScreen: true }),
77
+ // Sessions required so updateMemberRoles can revoke target JWTs via
78
+ // sessions:write:user-session:revoke-all-for-user (best-effort lookup).
79
+ authFoundationFeature,
80
+ createSessionsFeature(),
69
81
  createTemplateResolverFeature(),
70
82
  createRendererFoundationFeature(),
71
83
  createDeliveryFeature(),
@@ -103,6 +115,7 @@ beforeAll(async () => {
103
115
  tenantMembershipsTable,
104
116
  notificationPreferencesTable,
105
117
  });
118
+ await createEventsTable(stack.db);
106
119
  });
107
120
 
108
121
  afterAll(async () => {
@@ -115,6 +128,7 @@ beforeEach(async () => {
115
128
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
116
129
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
117
130
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
131
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${eventsTable.tableName}"`);
118
132
  emailTransport.sent.length = 0;
119
133
  const keys = await stack.redis.redis.keys("invite:*");
120
134
  if (keys.length > 0) await stack.redis.redis.del(...keys);
@@ -249,6 +263,15 @@ describe("regular User is denied members-admin surface", () => {
249
263
  regularUserB(),
250
264
  ),
251
265
  ],
266
+ [
267
+ "update-member-roles",
268
+ () =>
269
+ stack.http.write(
270
+ TenantHandlers.updateMemberRoles,
271
+ { userId: regularUserBId, tenantId: TENANT_B_ID, roles: ["Admin"] },
272
+ regularUserB(),
273
+ ),
274
+ ],
252
275
  ] as const) {
253
276
  const res = await fn();
254
277
  expect(res.status, label).toBe(403);
@@ -277,6 +300,35 @@ describe("privilege escalation via invite role", () => {
277
300
  expect(rows).toHaveLength(0);
278
301
  expect(emailTransport.sent).toHaveLength(0);
279
302
  });
303
+
304
+ test("Admin cannot invite as TenantAdmin (elevation guard)", async () => {
305
+ const { id: adminUserId } = await seedUser(stack.db, {
306
+ email: "admin-inviter@example.com",
307
+ displayName: "Admin Inviter",
308
+ });
309
+ await seedTenantMembership(stack.db, {
310
+ userId: adminUserId,
311
+ tenantId: TENANT_A_ID,
312
+ roles: ["Admin"],
313
+ });
314
+ const adminUser: SessionUser = { id: adminUserId, tenantId: TENANT_A_ID, roles: ["Admin"] };
315
+
316
+ const err = await stack.http.writeErr(
317
+ AuthHandlers.inviteCreate,
318
+ { email: "elevated-invitee@example.com", role: "TenantAdmin" },
319
+ adminUser,
320
+ );
321
+ expectErrorIncludes(err, "unassignable_membership_role");
322
+ });
323
+
324
+ test("TenantAdmin cannot invite with unknown unranked role (elevation guard)", async () => {
325
+ const err = await stack.http.writeErr(
326
+ AuthHandlers.inviteCreate,
327
+ { email: "unknown-role@example.com", role: "SuperCustomRole" },
328
+ tenantAdminA(),
329
+ );
330
+ expectErrorIncludes(err, "unassignable_membership_role");
331
+ });
280
332
  });
281
333
 
282
334
  describe("tenant isolation on cancel-invitation", () => {
@@ -555,13 +607,389 @@ describe("cancel-invitation on the /members surface genuinely cancels", () => {
555
607
  });
556
608
  });
557
609
 
558
- describe("updateMemberRoles not reachable by TenantAdmin", () => {
559
- test("TenantAdmin gets access_denied on updateMemberRoles", async () => {
610
+ describe("updateMemberRoles — TenantAdmin session-scoped path and safety gates", () => {
611
+ test("TenantAdmin can update roles of a member in own tenant", async () => {
612
+ const { id: memberUserId } = await seedUser(stack.db, {
613
+ email: "updatable-member@example.com",
614
+ displayName: "Updatable Member",
615
+ passwordHash: await hashPassword("pw-update-1234"),
616
+ emailVerified: true,
617
+ });
618
+ await seedTenantMembership(stack.db, {
619
+ userId: memberUserId,
620
+ tenantId: TENANT_A_ID,
621
+ roles: ["User"],
622
+ });
623
+
624
+ const res = await stack.http.writeOk(
625
+ TenantHandlers.updateMemberRoles,
626
+ { userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] },
627
+ tenantAdminA(),
628
+ );
629
+ expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
630
+
631
+ const rows = await selectMany(stack.db, tenantMembershipsTable, {
632
+ userId: memberUserId,
633
+ tenantId: TENANT_A_ID,
634
+ });
635
+ expect(rows).toHaveLength(1);
636
+ expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
637
+ });
638
+
639
+ test("TenantAdmin cannot update membership of a user in another tenant", async () => {
640
+ const { id: otherTenantMemberId } = await seedUser(stack.db, {
641
+ email: "other-tenant-member@example.com",
642
+ displayName: "Other Tenant Member",
643
+ passwordHash: await hashPassword("pw-other-1234"),
644
+ emailVerified: true,
645
+ });
646
+ await seedTenantMembership(stack.db, {
647
+ userId: otherTenantMemberId,
648
+ tenantId: TENANT_B_ID,
649
+ roles: ["User"],
650
+ });
651
+
560
652
  const err = await stack.http.writeErr(
561
653
  TenantHandlers.updateMemberRoles,
562
- { userId: tenantAdminAId, tenantId: TENANT_A_ID, roles: ["User"] },
654
+ { userId: otherTenantMemberId, tenantId: TENANT_B_ID, roles: ["Admin"] },
655
+ tenantAdminA(),
656
+ );
657
+ expectErrorIncludes(err, "membership_not_found");
658
+ });
659
+
660
+ test("TenantAdmin cannot assign reserved/global roles", async () => {
661
+ const { id: memberUserId } = await seedUser(stack.db, {
662
+ email: "reserved-role-target@example.com",
663
+ displayName: "Reserved Role Target",
664
+ passwordHash: await hashPassword("pw-res-1234"),
665
+ emailVerified: true,
666
+ });
667
+ await seedTenantMembership(stack.db, {
668
+ userId: memberUserId,
669
+ tenantId: TENANT_A_ID,
670
+ roles: ["User"],
671
+ });
672
+
673
+ for (const role of FORBIDDEN_ROLES) {
674
+ const err = await stack.http.writeErr(
675
+ TenantHandlers.updateMemberRoles,
676
+ { userId: memberUserId, tenantId: TENANT_A_ID, roles: [role] },
677
+ tenantAdminA(),
678
+ );
679
+ expectErrorIncludes(err, "access_denied");
680
+ expectErrorIncludes(err, "reserved_membership_role");
681
+ }
682
+ });
683
+
684
+ test("TenantAdmin can demote a member that currently holds an app-defined role", async () => {
685
+ const { id: memberUserId } = await seedUser(stack.db, {
686
+ email: "editor-demote-target@example.com",
687
+ displayName: "Editor Demote Target",
688
+ passwordHash: await hashPassword("pw-ed-1234"),
689
+ emailVerified: true,
690
+ });
691
+ await seedTenantMembership(stack.db, {
692
+ userId: memberUserId,
693
+ tenantId: TENANT_A_ID,
694
+ roles: ["Editor"],
695
+ });
696
+
697
+ const res = await stack.http.writeOk(
698
+ TenantHandlers.updateMemberRoles,
699
+ { userId: memberUserId, tenantId: TENANT_A_ID, roles: ["User"] },
700
+ tenantAdminA(),
701
+ );
702
+ expect(res).toMatchObject({ userId: memberUserId, roles: ["User"] });
703
+
704
+ const rows = await selectMany(stack.db, tenantMembershipsTable, {
705
+ userId: memberUserId,
706
+ tenantId: TENANT_A_ID,
707
+ });
708
+ expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["User"]);
709
+ });
710
+
711
+ test("TenantAdmin cannot assign unknown role (elevation guard)", async () => {
712
+ const { id: memberUserId } = await seedUser(stack.db, {
713
+ email: "elevation-guard-target@example.com",
714
+ displayName: "Elevation Guard Target",
715
+ passwordHash: await hashPassword("pw-elev-1234"),
716
+ emailVerified: true,
717
+ });
718
+ await seedTenantMembership(stack.db, {
719
+ userId: memberUserId,
720
+ tenantId: TENANT_A_ID,
721
+ roles: ["User"],
722
+ });
723
+
724
+ const err = await stack.http.writeErr(
725
+ TenantHandlers.updateMemberRoles,
726
+ { userId: memberUserId, tenantId: TENANT_A_ID, roles: ["SuperCustomRole"] },
563
727
  tenantAdminA(),
564
728
  );
565
729
  expectErrorIncludes(err, "access_denied");
730
+ expectErrorIncludes(err, "unassignable_membership_role");
731
+ });
732
+
733
+ test("Admin cannot elevate target to TenantAdmin or modify a TenantAdmin", async () => {
734
+ const { id: adminUserId } = await seedUser(stack.db, {
735
+ email: "plain-admin@example.com",
736
+ displayName: "Plain Admin",
737
+ passwordHash: await hashPassword("pw-adm-1234"),
738
+ emailVerified: true,
739
+ });
740
+ await seedTenantMembership(stack.db, {
741
+ userId: adminUserId,
742
+ tenantId: TENANT_A_ID,
743
+ roles: ["Admin"],
744
+ });
745
+ const adminUser: SessionUser = { id: adminUserId, tenantId: TENANT_A_ID, roles: ["Admin"] };
746
+
747
+ const { id: regularMemberId } = await seedUser(stack.db, {
748
+ email: "plain-member@example.com",
749
+ displayName: "Plain Member",
750
+ passwordHash: await hashPassword("pw-mem-1234"),
751
+ emailVerified: true,
752
+ });
753
+ await seedTenantMembership(stack.db, {
754
+ userId: regularMemberId,
755
+ tenantId: TENANT_A_ID,
756
+ roles: ["User"],
757
+ });
758
+
759
+ // 1. Admin tries to assign TenantAdmin to regular member -> rejected
760
+ const errElevate = await stack.http.writeErr(
761
+ TenantHandlers.updateMemberRoles,
762
+ { userId: regularMemberId, tenantId: TENANT_A_ID, roles: ["TenantAdmin"] },
763
+ adminUser,
764
+ );
765
+ expectErrorIncludes(errElevate, "unassignable_membership_role");
766
+
767
+ // 2. Admin tries to modify existing TenantAdmin -> rejected
768
+ const errModifyHigher = await stack.http.writeErr(
769
+ TenantHandlers.updateMemberRoles,
770
+ { userId: tenantAdminAId, tenantId: TENANT_A_ID, roles: ["Admin"] },
771
+ adminUser,
772
+ );
773
+ expectErrorIncludes(errModifyHigher, "unassignable_membership_role");
774
+ });
775
+
776
+ test("refuses demoting/removing the last TenantAdmin", async () => {
777
+ // tenantAdminA is currently the only TenantAdmin in TENANT_A_ID
778
+ const err = await stack.http.writeErr(
779
+ TenantHandlers.updateMemberRoles,
780
+ { userId: tenantAdminAId, tenantId: TENANT_A_ID, roles: ["User"] },
781
+ tenantAdminA(),
782
+ );
783
+ expectErrorIncludes(err, "last_tenant_admin");
784
+ expectErrorIncludes(err, "cannot demote the last tenant admin");
785
+
786
+ // Verify role remains unchanged
787
+ const rows = await selectMany(stack.db, tenantMembershipsTable, {
788
+ userId: tenantAdminAId,
789
+ tenantId: TENANT_A_ID,
790
+ });
791
+ expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["TenantAdmin"]);
792
+ });
793
+
794
+ test("allows demoting a TenantAdmin when another TenantAdmin exists", async () => {
795
+ const { id: secondAdminId } = await seedUser(stack.db, {
796
+ email: "second-admin@example.com",
797
+ displayName: "Second Admin",
798
+ passwordHash: await hashPassword("pw-adm2-1234"),
799
+ emailVerified: true,
800
+ });
801
+ await seedTenantMembership(stack.db, {
802
+ userId: secondAdminId,
803
+ tenantId: TENANT_A_ID,
804
+ roles: ["TenantAdmin"],
805
+ });
806
+
807
+ // Now there are 2 TenantAdmins (tenantAdminA and secondAdminId)
808
+ const res = await stack.http.writeOk(
809
+ TenantHandlers.updateMemberRoles,
810
+ { userId: secondAdminId, tenantId: TENANT_A_ID, roles: ["Admin"] },
811
+ tenantAdminA(),
812
+ );
813
+ expect(res).toMatchObject({ userId: secondAdminId, roles: ["Admin"] });
814
+
815
+ const rows = await selectMany(stack.db, tenantMembershipsTable, {
816
+ userId: secondAdminId,
817
+ tenantId: TENANT_A_ID,
818
+ });
819
+ expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
820
+ });
821
+
822
+ test("SystemAdmin can update member roles across tenants", async () => {
823
+ const { id: memberUserId } = await seedUser(stack.db, {
824
+ email: "sysadmin-cross-target@example.com",
825
+ displayName: "SysAdmin Target",
826
+ passwordHash: await hashPassword("pw-sys-1234"),
827
+ emailVerified: true,
828
+ });
829
+ await seedTenantMembership(stack.db, {
830
+ userId: memberUserId,
831
+ tenantId: TENANT_B_ID,
832
+ roles: ["User"],
833
+ });
834
+
835
+ const res = await stack.http.writeOk(
836
+ TenantHandlers.updateMemberRoles,
837
+ { userId: memberUserId, tenantId: TENANT_B_ID, roles: ["Admin"] },
838
+ TestUsers.systemAdmin,
839
+ );
840
+ expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_B_ID, roles: ["Admin"] });
841
+
842
+ const rows = await selectMany(stack.db, tenantMembershipsTable, {
843
+ userId: memberUserId,
844
+ tenantId: TENANT_B_ID,
845
+ });
846
+ expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
847
+ });
848
+
849
+ test("updateMemberRoles appends audit event and invalidates active sessions for target user", async () => {
850
+ const { id: memberUserId } = await seedUser(stack.db, {
851
+ email: "session-target@example.com",
852
+ displayName: "Session Target",
853
+ });
854
+ await seedTenantMembership(stack.db, {
855
+ userId: memberUserId,
856
+ tenantId: TENANT_A_ID,
857
+ roles: ["User"],
858
+ });
859
+
860
+ const sessionTarget1Id = crypto.randomUUID();
861
+ const sessionTarget2Id = crypto.randomUUID();
862
+ const sessionAdminId = crypto.randomUUID();
863
+ await seedRow(stack.db, userSessionTable, {
864
+ id: sessionTarget1Id,
865
+ userId: memberUserId,
866
+ tenantId: TENANT_A_ID,
867
+ createdAt: Temporal.Now.instant().subtract({ minutes: 30 }),
868
+ expiresAt: Temporal.Now.instant().add({ hours: 2 }),
869
+ });
870
+ await seedRow(stack.db, userSessionTable, {
871
+ id: sessionTarget2Id,
872
+ userId: memberUserId,
873
+ tenantId: TENANT_A_ID,
874
+ createdAt: Temporal.Now.instant().subtract({ minutes: 10 }),
875
+ expiresAt: Temporal.Now.instant().add({ hours: 2 }),
876
+ });
877
+ await seedRow(stack.db, userSessionTable, {
878
+ id: sessionAdminId,
879
+ userId: tenantAdminAId,
880
+ tenantId: TENANT_A_ID,
881
+ createdAt: Temporal.Now.instant().subtract({ minutes: 5 }),
882
+ expiresAt: Temporal.Now.instant().add({ hours: 2 }),
883
+ });
884
+
885
+ const res = await stack.http.writeOk(
886
+ TenantHandlers.updateMemberRoles,
887
+ { userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] },
888
+ tenantAdminA(),
889
+ );
890
+ expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
891
+
892
+ const [s1] = await selectMany(stack.db, userSessionTable, { id: sessionTarget1Id });
893
+ const [s2] = await selectMany(stack.db, userSessionTable, { id: sessionTarget2Id });
894
+ expect(s1?.revokedAt).not.toBeNull();
895
+ expect(s2?.revokedAt).not.toBeNull();
896
+
897
+ const [sAdmin] = await selectMany(stack.db, userSessionTable, { id: sessionAdminId });
898
+ expect(sAdmin?.revokedAt).toBeNull();
899
+
900
+ // Event-store actor is createSystemUser (handler rewrites via system
901
+ // stream), so filter by type + tenant — not the HTTP caller's user id.
902
+ const membershipEvents = await selectMany(stack.db, eventsTable, {
903
+ aggregateType: "tenant-membership",
904
+ });
905
+ const updateEvent = membershipEvents.find(
906
+ (e) => e.type === "tenant-membership.updated" && e.tenantId === TENANT_A_ID,
907
+ );
908
+ expect(updateEvent).toBeDefined();
909
+ expect(updateEvent?.payload).toMatchObject({
910
+ changes: { roles: JSON.stringify(["Admin"]) },
911
+ });
912
+ });
913
+ });
914
+
915
+ describe("invite + accept end-to-end integration against real stack", () => {
916
+ test("TenantAdmin invites user with Admin role, recipient accepts via invite-signup-complete and gains Admin membership", async () => {
917
+ const inviteeEmail = "invitee-e2e@example.com";
918
+ const inviteRes = (await stack.http.writeOk(
919
+ AuthHandlers.inviteCreate,
920
+ { email: inviteeEmail, role: "Admin" },
921
+ tenantAdminA(),
922
+ )) as { invitationId: string; email: string };
923
+
924
+ expect(inviteRes.email).toBe(inviteeEmail);
925
+
926
+ const sentMail = emailTransport.sent.find((m) => m.to.includes(inviteeEmail));
927
+ expect(sentMail).toBeDefined();
928
+ const tokenMatch = sentMail?.html?.match(/token=([a-zA-Z0-9_-]+)/);
929
+ expect(tokenMatch?.[1]).toBeDefined();
930
+ const token = tokenMatch![1];
931
+
932
+ const acceptRes = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
933
+ token,
934
+ email: inviteeEmail,
935
+ name: "Invited Admin",
936
+ password: "StrongValidPassword123!",
937
+ });
938
+ expect(acceptRes.status).toBe(200);
939
+ const acceptBody = (await acceptRes.json()) as { user?: { id?: string }; token?: string };
940
+ expect(acceptBody.user?.id).toBeDefined();
941
+ const newUserId = acceptBody.user!.id!;
942
+
943
+ const memberships = await selectMany(stack.db, tenantMembershipsTable, {
944
+ userId: newUserId,
945
+ tenantId: TENANT_A_ID,
946
+ });
947
+ expect(memberships).toHaveLength(1);
948
+ expect(JSON.parse(memberships[0]?.roles as string)).toEqual(["Admin"]);
949
+
950
+ const teamRows = await queryTeamList({}, tenantAdminA());
951
+ const pendingInvite = teamRows.find((r) => r.email === inviteeEmail && r.status === "pending");
952
+ expect(pendingInvite).toBeUndefined();
953
+ const activeMember = teamRows.find((r) => r.email === inviteeEmail && r.status === "active");
954
+ expect(activeMember).toBeDefined();
955
+
956
+ const newAdminSession: SessionUser = { id: newUserId, tenantId: TENANT_A_ID, roles: ["Admin"] };
957
+ const membersList = await stack.http.queryOk<readonly { userId: string }[]>(
958
+ TenantQueries.members,
959
+ {},
960
+ newAdminSession,
961
+ );
962
+ expect(membersList.some((m) => m.userId === newUserId)).toBe(true);
963
+ });
964
+ });
965
+
966
+ describe("members UI screens and actions integration against real stack", () => {
967
+ test("members screen and member-roles-edit actionForm execute through stack", async () => {
968
+ const membersScreen = stack.registry.getScreen(`tenant:screen:${MEMBERS_SCREEN_ID}`);
969
+ expect(membersScreen).toBeDefined();
970
+ expect(membersScreen?.type).toBe("projectionList");
971
+
972
+ const editRolesScreen = stack.registry.getScreen(
973
+ `tenant:screen:${MEMBER_ROLES_EDIT_SCREEN_ID}`,
974
+ );
975
+ expect(editRolesScreen).toBeDefined();
976
+ expect(editRolesScreen?.type).toBe("actionForm");
977
+
978
+ const { id: memberUserId } = await seedUser(stack.db, {
979
+ email: "ui-member@example.com",
980
+ displayName: "UI Member",
981
+ });
982
+ await seedTenantMembership(stack.db, {
983
+ userId: memberUserId,
984
+ tenantId: TENANT_A_ID,
985
+ roles: ["User"],
986
+ });
987
+
988
+ const res = await stack.http.writeOk(
989
+ TenantHandlers.updateMemberRoles,
990
+ { userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] },
991
+ tenantAdminA(),
992
+ );
993
+ expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
566
994
  });
567
995
  });
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import { access } from "@cosmicdrift/kumiko-framework/engine";
4
5
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
6
  import {
6
7
  createTestUser,
@@ -360,10 +361,10 @@ describe("scenario 7: access rules on handlers", () => {
360
361
  "SystemAdmin",
361
362
  ]);
362
363
  // updateMemberRoles akzeptiert "system" (für seed-migrations + ops-tooling)
363
- // PLUS "SystemAdmin" (echter Operator-Pfad). Symmetrisch zu create.
364
+ // PLUS access.admin ("TenantAdmin", "Admin", "SystemAdmin").
364
365
  expect(
365
366
  rolesOf(stack.registry.getWriteHandler(TenantHandlers.updateMemberRoles)?.access),
366
- ).toEqual(["system", "SystemAdmin"]);
367
+ ).toEqual(["system", ...access.admin]);
367
368
  expect(rolesOf(stack.registry.getQueryHandler(TenantQueries.list)?.access)).toEqual([
368
369
  "SystemAdmin",
369
370
  ]);
@@ -11,6 +11,8 @@ export const MEMBERS_SCREEN_ID = "members" as const;
11
11
 
12
12
  export const INVITE_CREATE_SCREEN_ID = "invite-create" as const;
13
13
 
14
+ export const MEMBER_ROLES_EDIT_SCREEN_ID = "member-roles-edit" as const;
15
+
14
16
  /** Client column-renderer for the /members screen's status column — see
15
17
  * `screen.columns[].renderer.react.__component` in screens.ts and
16
18
  * `tenantClient()`'s `columnRenderers` map. */
@@ -53,4 +55,5 @@ export const TenantQueries = {
53
55
  export const TenantErrors = {
54
56
  membershipNotFound: "membership_not_found",
55
57
  membershipAlreadyExists: "membership_already_exists",
58
+ lastTenantAdmin: "last_tenant_admin",
56
59
  } as const;
@@ -30,6 +30,7 @@ import { tenantEntity } from "./schema/tenant";
30
30
  import {
31
31
  createMembersScreen,
32
32
  inviteCreateScreen,
33
+ memberRolesEditScreen,
33
34
  tenantEditScreen,
34
35
  tenantListScreen,
35
36
  } from "./screens";
@@ -154,10 +155,10 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
154
155
  r.screen(tenantListScreen);
155
156
  r.screen(tenantEditScreen);
156
157
  // Tenant-admin team UI: one list (active members + pending invitations,
157
- // §2.6), invite via a drawer-hosted actionForm. No role-edit —
158
- // updateMemberRoles stays SystemAdmin-only. Screen access matches
159
- // handler access.admin.
158
+ // §2.6), invite via a drawer-hosted actionForm, role-edit via
159
+ // actionForm. Screen access matches handler access.admin.
160
160
  r.screen(createMembersScreen(options));
161
+ r.screen(memberRolesEditScreen);
161
162
  if (options?.inviteScreen) {
162
163
  r.screen(inviteCreateScreen);
163
164
  }
@@ -20,6 +20,7 @@ type TeamStatus = "active" | "pending";
20
20
 
21
21
  type TeamRow = {
22
22
  readonly id: string;
23
+ readonly userId: string | null;
23
24
  readonly email: string | null;
24
25
  readonly displayName: string | null;
25
26
  readonly roles: readonly string[];
@@ -165,6 +166,7 @@ export const teamListQuery = definePagedQueryHandler({
165
166
  const createdAt = row["createdAt"];
166
167
  return {
167
168
  id: String(row["id"]),
169
+ userId,
168
170
  email: decrypted?.email ?? null,
169
171
  displayName: decrypted?.displayName ?? null,
170
172
  roles: parseRoles(row["roles"]),
@@ -197,6 +199,7 @@ export const teamListQuery = definePagedQueryHandler({
197
199
  const expiresAt = row["expiresAt"];
198
200
  return {
199
201
  id: String(row["id"]),
202
+ userId: null,
200
203
  email: decryptedEmail,
201
204
  displayName: null,
202
205
  roles: typeof row["role"] === "string" ? [row["role"]] : [],