@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.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 (121) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
  3. package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
  4. package/src/audit/__tests__/constants.test.ts +8 -0
  5. package/src/audit/web/audit-log-detail-screen.tsx +30 -14
  6. package/src/audit/web/audit-log-screen.tsx +21 -7
  7. package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
  8. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
  9. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
  10. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
  11. package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
  12. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  13. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
  14. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
  15. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
  16. package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
  17. package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
  19. package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
  20. package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
  21. package/src/auth-email-password/i18n.ts +1 -0
  22. package/src/auth-email-password/magic-link-mail.ts +17 -1
  23. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
  24. package/src/auth-email-password/web/auth-client.ts +1 -7
  25. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
  26. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  27. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  28. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  29. package/src/custom-fields/constants.ts +4 -3
  30. package/src/custom-fields/feature.ts +7 -8
  31. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  32. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  33. package/src/delivery/db/queries/preferences.ts +3 -2
  34. package/src/delivery/handlers/log.query.ts +1 -0
  35. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
  36. package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
  37. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
  38. package/src/feature-toggles/feature.ts +24 -4
  39. package/src/feature-toggles/handlers/registered.query.ts +27 -16
  40. package/src/feature-toggles/i18n.ts +5 -0
  41. package/src/feature-toggles/web/client-plugin.tsx +1 -5
  42. package/src/feature-toggles/web/index.ts +0 -1
  43. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  44. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  45. package/src/file-derivatives/feature.ts +7 -6
  46. package/src/file-provider-s3/feature.ts +1 -2
  47. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  48. package/src/form-draft/handlers/cleanup.job.ts +31 -20
  49. package/src/form-draft/handlers/discard.write.ts +8 -8
  50. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  51. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  52. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
  53. package/src/jobs/feature.ts +7 -2
  54. package/src/jobs/handlers/detail.query.ts +22 -11
  55. package/src/jobs/handlers/list.query.ts +17 -6
  56. package/src/jobs/job-run-logger.ts +78 -36
  57. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  58. package/src/mail-transport-smtp/feature.ts +1 -2
  59. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  60. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  61. package/src/notes-history/handlers/add-note.write.ts +7 -3
  62. package/src/notes-history/web/notes-section.tsx +10 -2
  63. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  64. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  65. package/src/secrets/feature.ts +9 -2
  66. package/src/secrets/handlers/delete.write.ts +0 -4
  67. package/src/secrets/handlers/list.query.ts +0 -4
  68. package/src/secrets/handlers/set.write.ts +0 -4
  69. package/src/secrets/index.ts +3 -3
  70. package/src/shared/index.ts +1 -0
  71. package/src/shared/map-with-concurrency.test.ts +2 -2
  72. package/src/shared/session-field.ts +10 -0
  73. package/src/shared/session-locale-field.test.ts +9 -0
  74. package/src/shared/session-locale-field.ts +4 -1
  75. package/src/shared/session-timezone-field.ts +2 -1
  76. package/src/subscription-stripe/feature.ts +5 -11
  77. package/src/tags/__tests__/feature.test.ts +18 -5
  78. package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
  79. package/src/tags/constants.ts +3 -2
  80. package/src/tags/entity.ts +2 -0
  81. package/src/tags/feature.ts +20 -11
  82. package/src/tags/handlers/create-tag.write.ts +5 -2
  83. package/src/tags/handlers/delete-tag.write.ts +5 -2
  84. package/src/tags/handlers/update-tag.write.ts +5 -2
  85. package/src/tags/i18n.ts +4 -0
  86. package/src/tags/index.ts +2 -0
  87. package/src/tags/screens.ts +66 -0
  88. package/src/tags/web/client-plugin.tsx +1 -6
  89. package/src/tags/web/index.ts +1 -0
  90. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  91. package/src/template-resolver/seeding.ts +78 -6
  92. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  93. package/src/template-resolver/web/client-plugin.tsx +2 -1
  94. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  95. package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
  96. package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
  97. package/src/tenant/handlers/members.query.ts +6 -1
  98. package/src/tenant/handlers/team-list.query.ts +3 -1
  99. package/src/tenant/handlers/update-member-roles.write.ts +16 -2
  100. package/src/tenant/i18n.ts +2 -3
  101. package/src/tenant/screens.ts +3 -2
  102. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  103. package/src/tenant-settings/feature.ts +6 -30
  104. package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
  105. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  106. package/src/user/__tests__/user.integration.test.ts +156 -118
  107. package/src/user/handlers/create.write.ts +5 -4
  108. package/src/user/handlers/detail.query.ts +24 -2
  109. package/src/user/handlers/list.query.ts +19 -26
  110. package/src/user/handlers/update-roles.ts +23 -6
  111. package/src/user/handlers/update.write.ts +6 -5
  112. package/src/user/i18n.ts +0 -6
  113. package/src/user/roles-input-schema.ts +20 -0
  114. package/src/user/schema/user.ts +2 -3
  115. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  116. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  117. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  118. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
  119. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  120. package/src/user-profile/web/profile-screen.tsx +1 -1
  121. package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
@@ -74,7 +74,7 @@ describe("tenant members screen + handler access alignment", () => {
74
74
  const rowAction = screen.rowActions?.find((a) => a.id === "edit-roles");
75
75
  if (rowAction?.kind !== "navigate") throw new Error("expected a navigate row action");
76
76
  expect(rowAction.screen).toBe(MEMBER_ROLES_EDIT_SCREEN_ID);
77
- expect(rowAction.params).toEqual({ map: { userId: "userId" } });
77
+ expect(rowAction.params).toEqual({ map: { userId: "userId", roles: "roles" } });
78
78
  expect(rowAction.visible).toEqual({ field: "status", eq: "active" });
79
79
  });
80
80
 
@@ -85,6 +85,11 @@ describe("tenant members screen + handler access alignment", () => {
85
85
  throw new Error("expected member-roles-edit screen to be actionForm");
86
86
  expect(screen.handler).toBe(TenantHandlers.updateMemberRoles);
87
87
  expect(screen.fields["userId"]).toEqual({ type: "text", required: true });
88
+ const section = screen.layout.sections[0];
89
+ if (section === undefined || !("fields" in section)) {
90
+ throw new Error("expected member-roles-edit layout fields section");
91
+ }
92
+ expect(section.fields).toEqual([{ field: "userId", readOnly: true }, "roles"]);
88
93
  expect(screen.fields["roles"]).toEqual({
89
94
  type: "multiSelect",
90
95
  options: DEFAULT_INVITE_ROLE_OPTIONS,
@@ -32,12 +32,7 @@ import { createTemplateResolverFeature } from "../../template-resolver/feature";
32
32
  import { createUserFeature } from "../../user/feature";
33
33
  import { userEntity, userTable } from "../../user/schema/user";
34
34
  import { seedUser } from "../../user/seeding";
35
- import {
36
- MEMBER_ROLES_EDIT_SCREEN_ID,
37
- MEMBERS_SCREEN_ID,
38
- TenantHandlers,
39
- TenantQueries,
40
- } from "../constants";
35
+ import { MEMBERS_SCREEN_ID, TenantHandlers, TenantQueries } from "../constants";
41
36
  import { createTenantFeature } from "../feature";
42
37
  import { tenantInvitationEntity, tenantInvitationsTable } from "../invitation-table";
43
38
  import { tenantMembershipsTable } from "../membership-table";
@@ -649,6 +644,33 @@ describe("updateMemberRoles — TenantAdmin session-scoped path and safety gates
649
644
  expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
650
645
  });
651
646
 
647
+ test("TenantAdmin omits payload.tenantId — updates roles in own tenant (fallback)", async () => {
648
+ const { id: memberUserId } = await seedUser(stack.db, {
649
+ email: "tenant-fallback-member@example.com",
650
+ displayName: "Tenant Fallback Member",
651
+ passwordHash: await hashPassword("pw-fallback-1234"),
652
+ emailVerified: true,
653
+ });
654
+ await seedTenantMembership(stack.db, {
655
+ userId: memberUserId,
656
+ tenantId: TENANT_A_ID,
657
+ roles: ["User"],
658
+ });
659
+
660
+ const res = await stack.http.writeOk(
661
+ TenantHandlers.updateMemberRoles,
662
+ { userId: memberUserId, roles: ["Admin"] },
663
+ tenantAdminA(),
664
+ );
665
+ expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
666
+
667
+ const rows = await selectMany(stack.db, tenantMembershipsTable, {
668
+ userId: memberUserId,
669
+ tenantId: TENANT_A_ID,
670
+ });
671
+ expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
672
+ });
673
+
652
674
  test("TenantAdmin cannot update membership of a user in another tenant", async () => {
653
675
  const { id: otherTenantMemberId } = await seedUser(stack.db, {
654
676
  email: "other-tenant-member@example.com",
@@ -859,6 +881,78 @@ describe("updateMemberRoles — TenantAdmin session-scoped path and safety gates
859
881
  expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
860
882
  });
861
883
 
884
+ test("SystemAdmin omits payload.tenantId — uses session tenant (actionForm UI shape)", async () => {
885
+ const { id: memberUserId } = await seedUser(stack.db, {
886
+ email: "sysadmin-session-tenant@example.com",
887
+ displayName: "SysAdmin Session Tenant",
888
+ passwordHash: await hashPassword("pw-sys-sess-1234"),
889
+ emailVerified: true,
890
+ });
891
+ await seedTenantMembership(stack.db, {
892
+ userId: memberUserId,
893
+ tenantId: TENANT_A_ID,
894
+ roles: ["User"],
895
+ });
896
+
897
+ // Mirrors prod: SystemAdmin with an active tenant opens Edit roles; the
898
+ // actionForm only posts userId+roles (no tenantId field).
899
+ const sysAdminInTenant: SessionUser = {
900
+ id: TestUsers.systemAdmin.id,
901
+ tenantId: TENANT_A_ID,
902
+ roles: ["SystemAdmin", "TenantAdmin"],
903
+ };
904
+ const res = await stack.http.writeOk(
905
+ TenantHandlers.updateMemberRoles,
906
+ { userId: memberUserId, roles: ["Admin"] },
907
+ sysAdminInTenant,
908
+ );
909
+ expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
910
+
911
+ const rows = await selectMany(stack.db, tenantMembershipsTable, {
912
+ userId: memberUserId,
913
+ tenantId: TENANT_A_ID,
914
+ });
915
+ expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
916
+ });
917
+
918
+ test("SystemAdmin sending an explicit empty payload.tenantId is rejected, not silently falls back", async () => {
919
+ const { id: memberUserId } = await seedUser(stack.db, {
920
+ email: "no-tenant-target@example.com",
921
+ displayName: "No Tenant Target",
922
+ passwordHash: await hashPassword("pw-notenant-1234"),
923
+ emailVerified: true,
924
+ });
925
+ await seedTenantMembership(stack.db, {
926
+ userId: memberUserId,
927
+ tenantId: TENANT_A_ID,
928
+ roles: ["User"],
929
+ });
930
+
931
+ // `payloadTenantId ?? sessionTenantId` only falls back on null/undefined —
932
+ // a JWT always carries a valid session tenantId (jwt.verify rejects a
933
+ // malformed/empty tenantId claim), so the only way to reach the
934
+ // `!targetTenantId` branch through the real HTTP path is an explicit
935
+ // empty-string payload.tenantId, which `??` does NOT treat as absent.
936
+ const sysAdminInTenant: SessionUser = {
937
+ id: TestUsers.systemAdmin.id,
938
+ tenantId: TENANT_A_ID,
939
+ roles: ["SystemAdmin"],
940
+ };
941
+ const err = await stack.http.writeErr(
942
+ TenantHandlers.updateMemberRoles,
943
+ { userId: memberUserId, tenantId: "", roles: ["Admin"] },
944
+ sysAdminInTenant,
945
+ );
946
+ expectErrorIncludes(err, "validation_error");
947
+ expectErrorIncludes(err, "tenantId");
948
+
949
+ const rows = await selectMany(stack.db, tenantMembershipsTable, {
950
+ userId: memberUserId,
951
+ tenantId: TENANT_A_ID,
952
+ });
953
+ expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["User"]);
954
+ });
955
+
862
956
  test("updateMemberRoles appends audit event and invalidates active sessions for target user", async () => {
863
957
  const { id: memberUserId } = await seedUser(stack.db, {
864
958
  email: "session-target@example.com",
@@ -904,11 +998,14 @@ describe("updateMemberRoles — TenantAdmin session-scoped path and safety gates
904
998
 
905
999
  const [s1] = await selectMany(stack.db, userSessionTable, { id: sessionTarget1Id });
906
1000
  const [s2] = await selectMany(stack.db, userSessionTable, { id: sessionTarget2Id });
907
- expect(s1?.revokedAt).not.toBeNull();
908
- expect(s2?.revokedAt).not.toBeNull();
1001
+ expect(s1).toBeDefined();
1002
+ expect(s2).toBeDefined();
1003
+ expect(s1!.revokedAt).not.toBeNull();
1004
+ expect(s2!.revokedAt).not.toBeNull();
909
1005
 
910
1006
  const [sAdmin] = await selectMany(stack.db, userSessionTable, { id: sessionAdminId });
911
- expect(sAdmin?.revokedAt).toBeNull();
1007
+ expect(sAdmin).toBeDefined();
1008
+ expect(sAdmin!.revokedAt).toBeNull();
912
1009
 
913
1010
  // Event-store actor is createSystemUser (handler rewrites via system
914
1011
  // stream), so filter by type + tenant — not the HTTP caller's user id.
@@ -975,34 +1072,3 @@ describe("invite + accept end-to-end integration against real stack", () => {
975
1072
  expect(membersList.some((m) => m.userId === newUserId)).toBe(true);
976
1073
  });
977
1074
  });
978
-
979
- describe("members UI screens and actions integration against real stack", () => {
980
- test("members screen and member-roles-edit actionForm execute through stack", async () => {
981
- const membersScreen = stack.registry.getScreen(`tenant:screen:${MEMBERS_SCREEN_ID}`);
982
- expect(membersScreen).toBeDefined();
983
- expect(membersScreen?.type).toBe("projectionList");
984
-
985
- const editRolesScreen = stack.registry.getScreen(
986
- `tenant:screen:${MEMBER_ROLES_EDIT_SCREEN_ID}`,
987
- );
988
- expect(editRolesScreen).toBeDefined();
989
- expect(editRolesScreen?.type).toBe("actionForm");
990
-
991
- const { id: memberUserId } = await seedUser(stack.db, {
992
- email: "ui-member@example.com",
993
- displayName: "UI Member",
994
- });
995
- await seedTenantMembership(stack.db, {
996
- userId: memberUserId,
997
- tenantId: TENANT_A_ID,
998
- roles: ["User"],
999
- });
1000
-
1001
- const res = await stack.http.writeOk(
1002
- TenantHandlers.updateMemberRoles,
1003
- { userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] },
1004
- tenantAdminA(),
1005
- );
1006
- expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
1007
- });
1008
- });
@@ -18,7 +18,11 @@ const KMS_POOL_CONCURRENCY = 4;
18
18
 
19
19
  export const membersQuery = defineQueryHandler({
20
20
  name: "members",
21
- schema: z.object({}),
21
+ schema: z.object({
22
+ // Optional single-user filter — audit detail (and similar) only need one
23
+ // member's display name, not the full decrypted roster (fw#2291).
24
+ userId: z.string().min(1).optional(),
25
+ }),
22
26
  access: { roles: access.admin },
23
27
  handler: async (query, ctx) => {
24
28
  if (!ctx.systemDb) {
@@ -30,6 +34,7 @@ export const membersQuery = defineQueryHandler({
30
34
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
31
35
  const rows = await selectMany(db, tenantMembershipsTable, {
32
36
  tenantId: query.user.tenantId,
37
+ ...(query.payload.userId !== undefined ? { userId: query.payload.userId } : {}),
33
38
  });
34
39
 
35
40
  const userIds = [...new Set(rows.map((row) => row["userId"]))];
@@ -223,7 +223,9 @@ export const teamListQuery = definePagedQueryHandler({
223
223
  // the order of a few thousand members per tenant; past that the fix is
224
224
  // a real combined read-projection (a materialized view/table kept in
225
225
  // sync by both tenant-membership and tenant-invitation events), not a
226
- // cursor bolted onto this merge.
226
+ // cursor bolted onto this merge. Upgrade path: r.multiStreamProjection({
227
+ // name, apply }) — see custom-fields/wire-for-entity.ts,
228
+ // feature-toggles/feature.ts, step-dispatcher/feature.ts.
227
229
  const search = query.payload.search?.trim().toLowerCase();
228
230
  const merged = [...memberRows, ...invitationRows].filter((row) => {
229
231
  if (search === undefined || search === "") return true;
@@ -41,10 +41,20 @@ const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user"
41
41
  // (dispatcher batch wraps handlers in transaction — xact lock holds through update).
42
42
  const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 0x7461646d; // 'tadm'
43
43
 
44
+ /** SystemAdmin may pass payload.tenantId (cross-tenant); the members actionForm
45
+ * only sends userId+roles, so fall back to the active session tenant. */
46
+ function resolveTargetTenantId(
47
+ isSystem: boolean,
48
+ payloadTenantId: string | undefined,
49
+ sessionTenantId: string | undefined,
50
+ ): string | undefined {
51
+ return isSystem ? (payloadTenantId ?? sessionTenantId) : sessionTenantId;
52
+ }
53
+
44
54
  export const updateMemberRolesWrite = defineWriteHandler({
45
55
  name: "updateMemberRoles",
46
56
  schema: z.object({
47
- userId: z.string(),
57
+ userId: z.string().min(1),
48
58
  tenantId: z.string().optional(),
49
59
  roles: z.array(z.string()).min(1),
50
60
  }),
@@ -63,7 +73,11 @@ export const updateMemberRolesWrite = defineWriteHandler({
63
73
 
64
74
  const isSystem =
65
75
  event.user.roles.includes("SystemAdmin") || event.user.roles.includes("system");
66
- const targetTenantId = isSystem ? event.payload.tenantId : event.user.tenantId;
76
+ const targetTenantId = resolveTargetTenantId(
77
+ isSystem,
78
+ event.payload.tenantId,
79
+ event.user.tenantId,
80
+ );
67
81
  if (!targetTenantId) {
68
82
  return writeFailure(
69
83
  new ValidationError({
@@ -22,19 +22,18 @@ export const TENANT_I18N: Readonly<Record<string, LocalizedString>> = {
22
22
  },
23
23
  "tenant.members.invite.title": { en: "New invitation" },
24
24
  "tenant.members.invite.submit": { en: "Send invitation" },
25
- "tenant.members.roles.edit.title": { en: "Edit roles" },
26
25
  "tenant.members.roles.edit.submit": { en: "Save roles" },
27
26
  "tenant:entity:__action-form__:field:email": { en: "Email" },
28
27
  "tenant:entity:__action-form__:field:role": { en: "Role" },
29
28
  "tenant:entity:__action-form__:field:role:option:User": { en: "User" },
30
29
  "tenant:entity:__action-form__:field:role:option:Admin": { en: "Admin" },
31
- "tenant:entity:__action-form__:field:role:option:TenantAdmin": { en: "TenantAdmin" },
30
+ "tenant:entity:__action-form__:field:role:option:TenantAdmin": { en: "Tenant Admin" },
32
31
  "tenant:entity:__action-form__:field:role:option:Editor": { en: "Editor" },
33
32
  "tenant:entity:__action-form__:field:userId": { en: "User ID" },
34
33
  "tenant:entity:__action-form__:field:roles": { en: "Roles" },
35
34
  "tenant:entity:__action-form__:field:roles:option:User": { en: "User" },
36
35
  "tenant:entity:__action-form__:field:roles:option:Admin": { en: "Admin" },
37
- "tenant:entity:__action-form__:field:roles:option:TenantAdmin": { en: "TenantAdmin" },
36
+ "tenant:entity:__action-form__:field:roles:option:TenantAdmin": { en: "Tenant Admin" },
38
37
  "tenant:entity:__action-form__:field:roles:option:Editor": { en: "Editor" },
39
38
  "tenant:entity:tenant:field:key": { en: "Key" },
40
39
  "tenant:entity:tenant:field:name": { en: "Name" },
@@ -113,7 +113,7 @@ export function createMembersScreen(options?: {
113
113
  id: "edit-roles",
114
114
  label: "tenant.members.actions.editRoles",
115
115
  screen: MEMBER_ROLES_EDIT_SCREEN_ID,
116
- params: { map: { userId: "userId" } },
116
+ params: { map: { userId: "userId", roles: "roles" } },
117
117
  visible: { field: "status", eq: "active" },
118
118
  },
119
119
  {
@@ -172,7 +172,8 @@ export const memberRolesEditScreen = {
172
172
  roles: { type: "multiSelect", options: DEFAULT_INVITE_ROLE_OPTIONS, required: true },
173
173
  },
174
174
  layout: {
175
- sections: [{ fields: ["userId", "roles"] }],
175
+ // Prefill userId from rowAction; readOnly so the operator cannot retarget.
176
+ sections: [{ fields: [{ field: "userId", readOnly: true }, "roles"] }],
176
177
  },
177
178
  submitLabel: "tenant.members.roles.edit.submit",
178
179
  access: { roles: access.admin },
@@ -7,10 +7,16 @@
7
7
  // audience. This test proves the real, declared translations resolve to
8
8
  // actual text (not the raw key) AND that the two nav entries read
9
9
  // differently — a regression guard, not an existence check.
10
+ //
11
+ // de/es copy lives in the lockstep locale packages (fw#2350); the feature
12
+ // itself is English-only like the rest of bundled-features after 0.208.0.
10
13
 
11
14
  import { describe, expect, test } from "bun:test";
12
15
  import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
13
16
  import { buildConfigFeatureSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
18
+ import { localeEsBundle } from "@cosmicdrift/kumiko-locale-es";
19
+ import { translationsByLocaleFromKeys } from "@cosmicdrift/kumiko-renderer";
14
20
  import { createTenantSettingsFeature } from "../feature";
15
21
 
16
22
  function translate(
@@ -18,7 +24,29 @@ function translate(
18
24
  key: string,
19
25
  locale = "en",
20
26
  ): string {
21
- return translations[key]?.[locale] ?? key;
27
+ const byLocale = translationsByLocaleFromKeys(
28
+ translations as Record<string, Readonly<Record<string, string>>>,
29
+ );
30
+ return byLocale[locale]?.[key] ?? key;
31
+ }
32
+
33
+ function settingsHubLabelKeys(): readonly string[] {
34
+ const registry = createRegistry([createConfigFeature(), createTenantSettingsFeature()]);
35
+ const schema = buildConfigFeatureSchema(registry);
36
+ const renderedKeys: string[] = [];
37
+ for (const screenId of ["tenant-settings-system", "tenant-settings-tenant"]) {
38
+ const nav = schema.navs.find((n) => n.id === screenId);
39
+ const screen = schema.screens.find((s) => s.id === screenId);
40
+ if (nav === undefined || screen === undefined || screen.type !== "configEdit") {
41
+ throw new Error(`expected configEdit screen ${screenId}`);
42
+ }
43
+ const section = screen.layout.sections[0];
44
+ const sectionTitle = section !== undefined && "title" in section ? section.title : undefined;
45
+ if (sectionTitle === undefined) throw new Error(`expected section title on ${screenId}`);
46
+ renderedKeys.push(nav.label, `screen:${screenId}.title`, sectionTitle);
47
+ renderedKeys.push(...Object.values(screen.fieldLabels ?? {}));
48
+ }
49
+ return renderedKeys;
22
50
  }
23
51
 
24
52
  describe("tenant-settings — Settings-Hub nav/section labels", () => {
@@ -36,12 +64,8 @@ describe("tenant-settings — Settings-Hub nav/section labels", () => {
36
64
  const systemLabel = translate(translations, systemNav.label);
37
65
  const tenantLabel = translate(translations, tenantNav.label);
38
66
 
39
- // Both labels actually resolve to text — not an echoed raw key.
40
67
  expect(systemLabel).not.toBe(systemNav.label);
41
68
  expect(tenantLabel).not.toBe(tenantNav.label);
42
- // The cross-scope duplicate this test guards against: the two nav
43
- // entries read differently instead of repeating the same words under
44
- // both "Platform" and "Organization".
45
69
  expect(systemLabel).not.toBe(tenantLabel);
46
70
 
47
71
  const systemScreen = schema.screens.find((s) => s.id === "tenant-settings-system");
@@ -56,41 +80,35 @@ describe("tenant-settings — Settings-Hub nav/section labels", () => {
56
80
  expect(translate(translations, sectionTitle)).not.toBe(sectionTitle);
57
81
  });
58
82
 
59
- test("every Settings-Hub label the schema renders resolves in es, distinct from en", () => {
60
- const registry = createRegistry([createConfigFeature(), createTenantSettingsFeature()]);
61
- const schema = buildConfigFeatureSchema(registry);
62
- const translations = registry.getFeature("tenant-settings")?.translations ?? {};
63
-
64
- const renderedKeys: string[] = [];
65
- for (const screenId of ["tenant-settings-system", "tenant-settings-tenant"]) {
66
- const nav = schema.navs.find((n) => n.id === screenId);
67
- const screen = schema.screens.find((s) => s.id === screenId);
68
- expect(nav).toBeDefined();
69
- expect(screen).toBeDefined();
70
- if (nav === undefined || screen === undefined || screen.type !== "configEdit") {
71
- throw new Error("unreachable");
72
- }
73
- const section = screen.layout.sections[0];
74
- const sectionTitle = section !== undefined && "title" in section ? section.title : undefined;
75
- expect(sectionTitle).toBeDefined();
76
- if (sectionTitle === undefined) throw new Error("unreachable");
77
-
78
- renderedKeys.push(nav.label, `screen:${screenId}.title`, sectionTitle);
79
- renderedKeys.push(...Object.values(screen.fieldLabels ?? {}));
80
- }
81
-
82
- // The Settings-Hub renders exactly these label surfaces per scope: nav
83
- // entry, screen title, section heading and one label per masked config
84
- // key (currency + locale) — six distinct keys across both scopes.
83
+ test("every Settings-Hub label resolves in en on the feature and in de/es locale packs", () => {
84
+ const renderedKeys = settingsHubLabelKeys();
85
+ // nav + screen title + section + currency/locale field labels — six distinct keys.
85
86
  expect(new Set(renderedKeys).size).toBe(6);
86
87
 
88
+ const translations = createTenantSettingsFeature().translations ?? {};
87
89
  for (const key of renderedKeys) {
88
- expect(translate(translations, key, "es")).not.toBe(key);
89
- expect(translate(translations, key, "es")).not.toBe(translate(translations, key, "en"));
90
+ const en = translations[key]?.["en"];
91
+ expect(en).toBeString();
92
+ expect(en).not.toBe(key);
90
93
  }
91
94
 
92
- const systemLabel = translate(translations, "tenant-settings.settings.system", "es");
93
- const tenantLabel = translate(translations, "tenant-settings.settings", "es");
94
- expect(systemLabel).not.toBe(tenantLabel);
95
+ for (const [locale, bundle] of [
96
+ ["de", localeDeBundle],
97
+ ["es", localeEsBundle],
98
+ ] as const) {
99
+ for (const key of renderedKeys) {
100
+ const label = bundle[key];
101
+ expect(label, `${locale}:${key}`).toBeString();
102
+ expect(label).not.toBe(key);
103
+ expect(label).not.toBe(translations[key]?.["en"]);
104
+ }
105
+ }
106
+
107
+ expect(localeEsBundle["tenant-settings.settings.system"]).not.toBe(
108
+ localeEsBundle["tenant-settings.settings"],
109
+ );
110
+ expect(localeDeBundle["tenant-settings.settings.system"]).not.toBe(
111
+ localeDeBundle["tenant-settings.settings"],
112
+ );
95
113
  });
96
114
  });
@@ -29,45 +29,21 @@ export function createTenantSettingsFeature(
29
29
  r.config({ keys: buildTenantSettingsKeys(opts) });
30
30
  r.translations({
31
31
  keys: {
32
- "tenant-settings.currency": {
33
- de: "Standard-Währung",
34
- en: "Default Currency",
35
- es: "Moneda predeterminada",
36
- },
37
- "tenant-settings.locale": {
38
- de: "Standard-Sprache",
39
- en: "Default Locale",
40
- es: "Idioma predeterminado",
41
- },
32
+ "tenant-settings.currency": { en: "Default Currency" },
33
+ "tenant-settings.locale": { en: "Default Locale" },
42
34
  // Settings-Hub-derived configEdit screen title (buildConfigFeatureSchema) —
43
35
  // required by the i18n boot-validator whenever a feature masks config keys.
44
- "screen:tenant-settings-system.title": {
45
- de: "Tenant-Einstellungen",
46
- en: "Tenant Settings",
47
- es: "Ajustes de la organización",
48
- },
49
- "screen:tenant-settings-tenant.title": {
50
- de: "Tenant-Einstellungen",
51
- en: "Tenant Settings",
52
- es: "Ajustes de la organización",
53
- },
36
+ "screen:tenant-settings-system.title": { en: "Tenant Settings" },
37
+ "screen:tenant-settings-tenant.title": { en: "Tenant Settings" },
54
38
  // Settings-Hub nav label + configEdit section heading (same key, both
55
39
  // scopes fall back here — see buildConfigFeatureSchema).
56
- "tenant-settings.settings": {
57
- de: "Tenant-Einstellungen",
58
- en: "Tenant Settings",
59
- es: "Ajustes de la organización",
60
- },
40
+ "tenant-settings.settings": { en: "Tenant Settings" },
61
41
  // Both currency/locale are tenant-home with admin write access, which
62
42
  // cascades a SystemAdmin-only "set the platform default" screen up to
63
43
  // the system audience — scoped override so its nav entry reads
64
44
  // differently from the tenant-home entry instead of repeating the
65
45
  // same words under both "Platform" and "Organization".
66
- "tenant-settings.settings.system": {
67
- de: "Tenant-Einstellungen (Plattform-Standard)",
68
- en: "Tenant Settings (Platform Default)",
69
- es: "Ajustes de la organización (valor predeterminado de la plataforma)",
70
- },
46
+ "tenant-settings.settings.system": { en: "Tenant Settings (Platform Default)" },
71
47
  },
72
48
  });
73
49
  });
@@ -60,7 +60,8 @@ export function TierAdminScreen(): ReactNode {
60
60
  // shown (UI-level mis-grant guard). tenantId is a pure trigger here (the
61
61
  // body doesn't read it) — Biome's "extra dep" autofix would empty the deps
62
62
  // and limit the reset to mount.
63
- // biome-ignore lint/correctness/useExhaustiveDependencies: tenantId is the intended reset trigger, do not remove. kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
63
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
64
+ // biome-ignore lint/correctness/useExhaustiveDependencies: tenantId is the intended reset trigger, do not remove.
64
65
  useEffect(() => {
65
66
  setTier("");
66
67
  setStatus({ kind: "idle" });
@@ -92,7 +93,7 @@ export function TierAdminScreen(): ReactNode {
92
93
  const canSubmit = tenantId !== "" && tier !== "" && !submitting;
93
94
 
94
95
  return (
95
- <FormScreenShell testId="tier-admin-screen" className="flex flex-col gap-6">
96
+ <FormScreenShell testId="tier-admin-screen" maxWidth="4xl" className="flex flex-col gap-6">
96
97
  {tenantsQuery.error !== null && (
97
98
  <Banner variant="error" testId="tier-admin-load-error">
98
99
  {t("tier-admin.error.load")}
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { rolesInputSchema } from "../roles-input-schema";
3
+
4
+ describe("rolesInputSchema", () => {
5
+ test("accepts string[]", () => {
6
+ expect(rolesInputSchema.parse(["SystemAdmin"])).toEqual(["SystemAdmin"]);
7
+ });
8
+
9
+ test("accepts JSON-encoded string[]", () => {
10
+ expect(rolesInputSchema.parse(JSON.stringify(["SystemAdmin"]))).toEqual(["SystemAdmin"]);
11
+ });
12
+
13
+ test("rejects bare role name that would silently clear via parseRoles", () => {
14
+ expect(() => rolesInputSchema.parse("SystemAdmin")).toThrow();
15
+ });
16
+
17
+ test("accepts empty JSON array", () => {
18
+ expect(rolesInputSchema.parse("[]")).toEqual([]);
19
+ });
20
+ });