From 0967e0b95a99bf7f63c6311102994b8b070f67c0 Mon Sep 17 00:00:00 2001 From: Julian Haupt Date: Tue, 16 Jun 2026 22:02:44 +0200 Subject: [PATCH] admin: manage group tenant memberships --- public/locales/de-DE/translation.json | 2 ++ public/locales/en-US/translation.json | 2 ++ src/api/resources/tenant.js | 5 ++++ src/api/resources/tenant.test.js | 9 +++++++ src/pages/Admin.jsx | 11 ++++++++ src/pages/Admin.test.jsx | 36 +++++++++++++++++++++++++++ src/state.js | 1 + src/state.test.js | 1 + 8 files changed, 67 insertions(+) diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5f7d6a9..1e0abe2 100644 --- a/public/locales/de-DE/translation.json +++ b/public/locales/de-DE/translation.json @@ -608,6 +608,8 @@ "members": "Mitglieder", "no-members": "Diese Gruppe hat keine Mitglieder.", "add-member": "Mitglied hinzufügen", + "no-tenants": "Diese Gruppe ist derzeit kein Mitglied eines Mandanten.", + "add-to-tenant": "Zu Mandant hinzufügen", "success-created": "Neue Gruppe erfolgreich erstellt.", "success-updated": "Gruppe aktualisiert.", "success-deleted": "Gruppe gelöscht.", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index ac20fbe..9e52b7c 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -608,6 +608,8 @@ "members": "Members", "no-members": "This group has no members.", "add-member": "Add member", + "no-tenants": "This group is currently not a member of any tenant.", + "add-to-tenant": "Add to tenant", "success-created": "Successfully created new group.", "success-updated": "Group updated.", "success-deleted": "Group deleted.", diff --git a/src/api/resources/tenant.js b/src/api/resources/tenant.js index 316310e..5cd56a7 100644 --- a/src/api/resources/tenant.js +++ b/src/api/resources/tenant.js @@ -5,6 +5,10 @@ const get_user_tenants = (state, user_name) => // TODO remove `?? 'demo'` when we have working authentication GET(`/tenant?userMember=${user_name ?? 'demo'}&maxResults=50&firstResult=0`, state, state.api.tenant.by_member) +// Tenants the given group is a member of (used on the group details page). +const get_group_tenants = (state, group_id) => + GET(`/tenant?groupMember=${group_id}&maxResults=50&firstResult=0`, state, state.api.tenant.by_group_member) + const get_tenants = (state) => GET(`/tenant?firstResult=0&maxResults=50&sortBy=id&sortOrder=asc`, state, state.api.tenant.list) @@ -55,6 +59,7 @@ const tenant = { update: update_tenant, delete: delete_tenant, by_member: get_user_tenants, + by_group_member: get_group_tenants, user_members: get_tenant_users, group_members: get_tenant_groups, add_user: add_user_to_tenant, diff --git a/src/api/resources/tenant.test.js b/src/api/resources/tenant.test.js index 72662dd..d5d6fe8 100644 --- a/src/api/resources/tenant.test.js +++ b/src/api/resources/tenant.test.js @@ -76,6 +76,15 @@ describe("api/resources/tenant", () => { }); }); + it("by_group_member() uses the given group", () => { + tenant.by_group_member(state, "admins"); + expect_api_call(GET, { + url: "/tenant?groupMember=admins&maxResults=50&firstResult=0", + state, + signal: state.api.tenant.by_group_member, + }); + }); + it("user_members() GETs /user filtered by memberOfTenant", () => { tenant.user_members(state, "acme"); expect_api_call(GET, { diff --git a/src/pages/Admin.jsx b/src/pages/Admin.jsx index 92ac95e..54f006a 100644 --- a/src/pages/Admin.jsx +++ b/src/pages/Admin.jsx @@ -349,6 +349,7 @@ const GroupDetails = ({ group_id }) => { form.value = null void engine_rest.group.all(state) void engine_rest.group.members(state, group_id) + void engine_rest.tenant.by_group_member(state, group_id) // eslint-disable-next-line react-hooks/exhaustive-deps }, [group_id]) @@ -405,6 +406,16 @@ const GroupDetails = ({ group_id }) => { on_remove={(member_id) => engine_rest.group.remove_member(state, group_id, member_id)} refetch={() => engine_rest.group.members(state, group_id)} /> + engine_rest.tenant.add_group(state, tenant_id, group_id)} + on_remove={(tenant_id) => engine_rest.tenant.remove_group(state, tenant_id, group_id)} + refetch={() => engine_rest.tenant.by_group_member(state, group_id)} /> +

{t("admin.danger-zone")}

diff --git a/src/pages/Admin.test.jsx b/src/pages/Admin.test.jsx index 250f5d9..1b1eab3 100644 --- a/src/pages/Admin.test.jsx +++ b/src/pages/Admin.test.jsx @@ -241,6 +241,7 @@ describe("AdminPage", () => { const { container } = renderPage(state); expect(engine_rest.group.all).toHaveBeenCalled(); expect(engine_rest.group.members).toHaveBeenCalled(); + expect(engine_rest.tenant.by_group_member).toHaveBeenCalled(); expect(container.querySelector("#group-name").value).toBe("Admins"); }); @@ -260,6 +261,41 @@ describe("AdminPage", () => { expect(call[1]).toBe("g1"); expect(call[2]).toBe("alice"); }); + + it("adds a tenant membership from the group details page", () => { + mockParams = { page_id: "groups", selection_id: "g1" }; + signal_response(state.api.group.list, [{ id: "g1", name: "Admins" }]); + const { container, getAllByText } = renderPage(state); + + fireEvent.click(getAllByText("admin.group.add-to-tenant")[0]); + const input = container.querySelector("dialog[open] #member-id"); + fireEvent.input(input, { target: { value: "tenant-a" } }); + fireEvent.submit(input.closest("form")); + + expect(engine_rest.tenant.add_group).toHaveBeenCalled(); + const call = engine_rest.tenant.add_group.mock.lastCall; + expect(call[0]).toBe(state); + expect(call[1]).toBe("tenant-a"); + expect(call[2]).toBe("g1"); + }); + + it("removes a tenant membership from the group details page", () => { + mockParams = { page_id: "groups", selection_id: "g1" }; + signal_response(state.api.group.list, [{ id: "g1", name: "Admins" }]); + signal_response(state.api.tenant.by_group_member, [ + { id: "tenant-a", name: "Tenant A" }, + ]); + const { container } = renderPage(state); + + fireEvent.click(container.querySelector("table button.danger")); + fireEvent.click(container.querySelector("dialog[open] button.danger")); + + expect(engine_rest.tenant.remove_group).toHaveBeenCalled(); + const call = engine_rest.tenant.remove_group.mock.lastCall; + expect(call[0]).toBe(state); + expect(call[1]).toBe("tenant-a"); + expect(call[2]).toBe("g1"); + }); }); describe("Tenants", () => { diff --git a/src/state.js b/src/state.js index f63ad2e..94fc246 100644 --- a/src/state.js +++ b/src/state.js @@ -87,6 +87,7 @@ const createAppState = () => { tenant: { list: signal(null), by_member: signal(null), + by_group_member: signal(null), create: signal(null), update: signal(null), delete: signal(null), diff --git a/src/state.test.js b/src/state.test.js index c6b9968..8974f32 100644 --- a/src/state.test.js +++ b/src/state.test.js @@ -53,6 +53,7 @@ describe("state", () => { const { api } = createAppState(); expect(api.process.definition.list.value).toBeNull(); expect(api.process.instance.one.value).toBeNull(); + expect(api.tenant.by_group_member.value).toBeNull(); expect(api.task.comment.list.value).toBeNull(); expect(api.authorization.all.value).toBeNull(); expect(api.batch.list.value).toBeNull();