@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.
- package/package.json +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -7
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
- package/src/feature-toggles/feature.ts +24 -4
- package/src/feature-toggles/handlers/registered.query.ts +27 -16
- package/src/feature-toggles/i18n.ts +5 -0
- package/src/feature-toggles/web/client-plugin.tsx +1 -5
- package/src/feature-toggles/web/index.ts +0 -1
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +7 -6
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/secrets/handlers/delete.write.ts +0 -4
- package/src/secrets/handlers/list.query.ts +0 -4
- package/src/secrets/handlers/set.write.ts +0 -4
- package/src/secrets/index.ts +3 -3
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/__tests__/feature.test.ts +18 -5
- package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
- package/src/tags/constants.ts +3 -2
- package/src/tags/entity.ts +2 -0
- package/src/tags/feature.ts +20 -11
- package/src/tags/handlers/create-tag.write.ts +5 -2
- package/src/tags/handlers/delete-tag.write.ts +5 -2
- package/src/tags/handlers/update-tag.write.ts +5 -2
- package/src/tags/i18n.ts +4 -0
- package/src/tags/index.ts +2 -0
- package/src/tags/screens.ts +66 -0
- package/src/tags/web/client-plugin.tsx +1 -6
- package/src/tags/web/index.ts +1 -0
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +3 -1
- package/src/tenant/handlers/update-member-roles.write.ts +16 -2
- package/src/tenant/i18n.ts +2 -3
- package/src/tenant/screens.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +23 -6
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/i18n.ts +0 -6
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
- 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
|
|
908
|
-
expect(s2
|
|
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
|
|
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 =
|
|
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({
|
package/src/tenant/i18n.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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" },
|
package/src/tenant/screens.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
const
|
|
61
|
-
|
|
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
|
-
|
|
89
|
-
expect(
|
|
90
|
+
const en = translations[key]?.["en"];
|
|
91
|
+
expect(en).toBeString();
|
|
92
|
+
expect(en).not.toBe(key);
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
});
|