From 776a491989ebc9be435dea0cb57943e2ad6b65cf Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Thu, 22 Aug 2024 20:44:03 +0200 Subject: [PATCH] added organizations table to account (#32311) * added organizations table to account Signed-off-by: Erik Jan de Wit Signed-off-by: Pedro Igor Co-authored-by: Pedro Igor --- .../account/OrganizationRepresentation.java | 97 +++++++++++++++++++ .../theme/keycloak.v3/account/index.ftl | 1 + .../account/messages/messages_en.properties | 10 +- js/apps/account-ui/public/content.json | 5 + js/apps/account-ui/src/api/methods.ts | 6 ++ js/apps/account-ui/src/environment.ts | 1 + .../src/organizations/Organizations.tsx | 48 +++++++++ js/apps/account-ui/src/routes.tsx | 7 ++ .../organizations/OrganizationsSection.tsx | 21 +++- js/apps/admin-ui/src/user/Organizations.tsx | 19 +++- .../src/controls}/OrganizationTable.tsx | 69 +++++++------ js/libs/ui-shared/src/main.ts | 1 + .../resources/account/AccountConsole.java | 1 + .../resources/account/AccountRestService.java | 23 +++-- .../account/OrganizationsResource.java | 75 ++++++++++++++ .../account/OrganizationAccountTest.java | 44 +++++++++ 16 files changed, 385 insertions(+), 43 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/account/OrganizationRepresentation.java create mode 100644 js/apps/account-ui/src/organizations/Organizations.tsx rename js/{apps/admin-ui/src/organizations => libs/ui-shared/src/controls}/OrganizationTable.tsx (64%) create mode 100644 services/src/main/java/org/keycloak/services/resources/account/OrganizationsResource.java diff --git a/core/src/main/java/org/keycloak/representations/account/OrganizationRepresentation.java b/core/src/main/java/org/keycloak/representations/account/OrganizationRepresentation.java new file mode 100644 index 00000000000..12b1660743e --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/account/OrganizationRepresentation.java @@ -0,0 +1,97 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.representations.account; + +import java.util.Set; + +public class OrganizationRepresentation { + + private String id; + private String name; + private String alias; + private boolean enabled = true; + private String description; + private Set domains; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getDescription() { + return this.description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Set getDomains() { + return domains; + } + + public void setDomains(Set domains) { + this.domains = domains; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + if (!(o instanceof OrganizationRepresentation)) return false; + + OrganizationRepresentation that = (OrganizationRepresentation) o; + + return id != null && id.equals(that.getId()); + } + + @Override + public int hashCode() { + if (id == null) { + return super.hashCode(); + } + return id.hashCode(); + } +} diff --git a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/index.ftl b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/index.ftl index 47332e2b589..f75cd0e8a7e 100644 --- a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/index.ftl +++ b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/index.ftl @@ -128,6 +128,7 @@ "isInternationalizationEnabled": ${realm.isInternationalizationEnabled()?c}, "isLinkedAccountsEnabled": ${realm.identityFederationEnabled?c}, "isMyResourcesEnabled": ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c}, + "isViewOrganizationsEnabled": ${isViewOrganizationsEnabled?c}, "deleteAccountAllowed": ${deleteAccountAllowed?c}, "updateEmailFeatureEnabled": ${updateEmailFeatureEnabled?c}, "updateEmailActionEnabled": ${updateEmailActionEnabled?c}, diff --git a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties index d0fed75ff7b..2fa1c764725 100644 --- a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties +++ b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties @@ -205,4 +205,12 @@ addressScopeConsentText=Address phoneScopeConsentText=Phone number offlineAccessScopeConsentText=Offline Access samlRoleListScopeConsentText=My Roles -rolesScopeConsentText=User roles \ No newline at end of file +rolesScopeConsentText=User roles +organizations=Organizations +organizationDescription=View organizations that you joined +emptyUserOrganizations=No organizations +emptyUserOrganizationsInstructions=You have not joined any organizations yet. +searchOrganization=Search for organization +organizationList=List of organizations +domains=Domains +refresh=Refresh \ No newline at end of file diff --git a/js/apps/account-ui/public/content.json b/js/apps/account-ui/public/content.json index 423160303a5..40b1932ea9e 100644 --- a/js/apps/account-ui/public/content.json +++ b/js/apps/account-ui/public/content.json @@ -18,6 +18,11 @@ "path": "groups", "isVisible": "isViewGroupsEnabled" }, + { + "label": "organizations", + "path": "organizations", + "isVisible": "isViewOrganizationsEnabled" + }, { "label": "resources", "path": "resources", diff --git a/js/apps/account-ui/src/api/methods.ts b/js/apps/account-ui/src/api/methods.ts index 101400da284..a40fe8a0279 100644 --- a/js/apps/account-ui/src/api/methods.ts +++ b/js/apps/account-ui/src/api/methods.ts @@ -3,6 +3,7 @@ import { type KeycloakContext, } from "@keycloak/keycloak-ui-shared"; +import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation"; import { joinPath } from "../utils/joinPath"; import { parseResponse } from "./parse-response"; import { @@ -156,3 +157,8 @@ export async function getGroups({ signal, context }: CallOptions) { }); return parseResponse(response); } + +export async function getUserOrganizations({ signal, context }: CallOptions) { + const response = await request("/organizations", context, { signal }); + return parseResponse(response); +} diff --git a/js/apps/account-ui/src/environment.ts b/js/apps/account-ui/src/environment.ts index 0ed590d6790..8ff79068cca 100644 --- a/js/apps/account-ui/src/environment.ts +++ b/js/apps/account-ui/src/environment.ts @@ -25,6 +25,7 @@ export type Feature = { updateEmailFeatureEnabled: boolean; updateEmailActionEnabled: boolean; isViewGroupsEnabled: boolean; + isViewOrganizationsEnabled: boolean; isOid4VciEnabled: boolean; }; diff --git a/js/apps/account-ui/src/organizations/Organizations.tsx b/js/apps/account-ui/src/organizations/Organizations.tsx new file mode 100644 index 00000000000..ae36d0f5c4e --- /dev/null +++ b/js/apps/account-ui/src/organizations/Organizations.tsx @@ -0,0 +1,48 @@ +import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation"; +import { + ErrorBoundaryProvider, + KeycloakSpinner, + ListEmptyState, + OrganizationTable, + useEnvironment, +} from "@keycloak/keycloak-ui-shared"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { getUserOrganizations } from "../api/methods"; +import { Page } from "../components/page/Page"; +import { Environment } from "../environment"; +import { usePromise } from "../utils/usePromise"; + +export const Organizations = () => { + const { t } = useTranslation(); + const context = useEnvironment(); + + const [userOrgs, setUserOrgs] = useState([]); + + usePromise( + (signal) => getUserOrganizations({ signal, context }), + setUserOrgs, + ); + + if (!userOrgs) { + return ; + } + + return ( + + + {children}} + loader={userOrgs} + > + + + + + ); +}; + +export default Organizations; diff --git a/js/apps/account-ui/src/routes.tsx b/js/apps/account-ui/src/routes.tsx index a54e8b1f467..3e549d63107 100644 --- a/js/apps/account-ui/src/routes.tsx +++ b/js/apps/account-ui/src/routes.tsx @@ -2,6 +2,7 @@ import { lazy } from "react"; import type { IndexRouteObject, RouteObject } from "react-router-dom"; import { environment } from "./environment"; +import { Organizations } from "./organizations/Organizations"; import { ErrorPage } from "./root/ErrorPage"; import { Root } from "./root/Root"; @@ -59,6 +60,11 @@ export const PersonalInfoRoute: IndexRouteObject = { element: , }; +export const OrganizationsRoute: RouteObject = { + path: "organizations", + element: , +}; + export const Oid4VciRoute: RouteObject = { path: "oid4vci", element: , @@ -75,6 +81,7 @@ export const RootRoute: RouteObject = { SigningInRoute, ApplicationsRoute, GroupsRoute, + OrganizationsRoute, PersonalInfoRoute, ResourcesRoute, ContentRoute, diff --git a/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx b/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx index e778a117ec1..a183221cdd3 100644 --- a/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx +++ b/js/apps/admin-ui/src/organizations/OrganizationsSection.tsx @@ -1,5 +1,9 @@ import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation"; -import { useAlerts } from "@keycloak/keycloak-ui-shared"; +import { + ListEmptyState, + OrganizationTable, + useAlerts, +} from "@keycloak/keycloak-ui-shared"; import { Button, ButtonVariant, @@ -11,10 +15,9 @@ import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; import { useAdminClient } from "../admin-client"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; -import { ListEmptyState } from "@keycloak/keycloak-ui-shared"; import { ViewHeader } from "../components/view-header/ViewHeader"; import { useRealm } from "../context/realm-context/RealmContext"; -import { OrganizationTable } from "./OrganizationTable"; +import { toEditOrganization } from "../organizations/routes/EditOrganization"; import { toAddOrganization } from "./routes/AddOrganization"; export default function OrganizationSection() { @@ -61,6 +64,18 @@ export default function OrganizationSection() { ( + + {children} + + )} key={key} loader={loader} isPaginated diff --git a/js/apps/admin-ui/src/user/Organizations.tsx b/js/apps/admin-ui/src/user/Organizations.tsx index 49595f7a9ef..4ead99e6d43 100644 --- a/js/apps/admin-ui/src/user/Organizations.tsx +++ b/js/apps/admin-ui/src/user/Organizations.tsx @@ -1,6 +1,7 @@ import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation"; import { ListEmptyState, + OrganizationTable, useAlerts, useFetch, } from "@keycloak/keycloak-ui-shared"; @@ -15,11 +16,12 @@ import { } from "@patternfly/react-core"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { useAdminClient } from "../admin-client"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { useRealm } from "../context/realm-context/RealmContext"; import { OrganizationModal } from "../organizations/OrganizationModal"; -import { OrganizationTable } from "../organizations/OrganizationTable"; +import { toEditOrganization } from "../organizations/routes/EditOrganization"; import useToggle from "../utils/useToggle"; import { UserParams } from "./routes/User"; @@ -28,6 +30,7 @@ export const Organizations = () => { const { t } = useTranslation(); const { id } = useParams(); const { addAlert, addError } = useAlerts(); + const { realm } = useRealm(); const [key, setKey] = useState(0); const refresh = () => setKey(key + 1); @@ -116,6 +119,18 @@ export const Organizations = () => { )} ( + + {children} + + )} loader={userOrgs} onSelect={(orgs) => setSelectedOrgs(orgs)} deleteLabel="remove" diff --git a/js/apps/admin-ui/src/organizations/OrganizationTable.tsx b/js/libs/ui-shared/src/controls/OrganizationTable.tsx similarity index 64% rename from js/apps/admin-ui/src/organizations/OrganizationTable.tsx rename to js/libs/ui-shared/src/controls/OrganizationTable.tsx index ff137b0debc..85c1780e719 100644 --- a/js/apps/admin-ui/src/organizations/OrganizationTable.tsx +++ b/js/libs/ui-shared/src/controls/OrganizationTable.tsx @@ -1,29 +1,23 @@ import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation"; import { Badge, Chip, ChipGroup } from "@patternfly/react-core"; import { TableText } from "@patternfly/react-table"; -import { PropsWithChildren, ReactNode } from "react"; +import { FunctionComponent, PropsWithChildren, ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; -import { - KeycloakDataTable, - LoaderFunction, -} from "@keycloak/keycloak-ui-shared"; -import { useRealm } from "../context/realm-context/RealmContext"; -import { toEditOrganization } from "./routes/EditOrganization"; +import { KeycloakDataTable, LoaderFunction } from "./table/KeycloakDataTable"; -const OrgDetailLink = (organization: OrganizationRepresentation) => { +type OrgDetailLinkProps = { + link: FunctionComponent< + PropsWithChildren<{ organization: OrganizationRepresentation }> + >; + organization: OrganizationRepresentation; +}; + +const OrgDetailLink = ({ link, organization }: OrgDetailLinkProps) => { const { t } = useTranslation(); - const { realm } = useRealm(); + const Component = link; return ( - + {organization.name} {!organization.enabled && ( { {t("disabled")} )} - + ); }; @@ -47,11 +41,14 @@ const Domains = (org: OrganizationRepresentation) => { expandedText={t("hide")} collapsedText={t("showRemaining")} > - {org.domains?.map((dn) => ( - - {dn.name} - - ))} + {org.domains?.map((dn) => { + const name = typeof dn === "string" ? dn : dn.name; + return ( + + {name} + + ); + })} ); }; @@ -60,6 +57,9 @@ type OrganizationTableProps = PropsWithChildren & { loader: | LoaderFunction | OrganizationRepresentation[]; + link: FunctionComponent< + PropsWithChildren<{ organization: OrganizationRepresentation }> + >; toolbarItem?: ReactNode; isPaginated?: boolean; onSelect?: (orgs: OrganizationRepresentation[]) => void; @@ -74,6 +74,7 @@ export const OrganizationTable = ({ onSelect, onDelete, deleteLabel = "delete", + link, children, }: OrganizationTableProps) => { const { t } = useTranslation(); @@ -87,17 +88,23 @@ export const OrganizationTable = ({ toolbarItem={toolbarItem} onSelect={onSelect} canSelectAll={onSelect !== undefined} - actions={[ - { - title: t(deleteLabel), - onRowClick: onDelete, - }, - ]} + actions={ + onDelete + ? [ + { + title: t(deleteLabel), + onRowClick: onDelete, + }, + ] + : undefined + } columns={[ { name: "name", displayKey: "name", - cellRenderer: OrgDetailLink, + cellRenderer: (row) => ( + + ), }, { name: "domains", diff --git a/js/libs/ui-shared/src/main.ts b/js/libs/ui-shared/src/main.ts index ab6a6ceb880..6eb7c5d760a 100644 --- a/js/libs/ui-shared/src/main.ts +++ b/js/libs/ui-shared/src/main.ts @@ -92,3 +92,4 @@ export { ErrorBoundaryProvider, } from "./utils/ErrorBoundary"; export type { FallbackProps } from "./utils/ErrorBoundary"; +export { OrganizationTable } from "./controls/OrganizationTable"; diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java index 778a5e022a8..ffef37aa37d 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java @@ -160,6 +160,7 @@ public class AccountConsole implements AccountResourceProvider { map.put("deleteAccountAllowed", deleteAccountAllowed); map.put("isViewGroupsEnabled", isViewGroupsEnabled); + map.put("isViewOrganizationsEnabled", Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)); map.put("isOid4VciEnabled", Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)); map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)); diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index a49a57fcb74..8cbbaf5663b 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -46,6 +46,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.jboss.resteasy.reactive.NoCache; +import org.keycloak.common.Profile.Feature; import org.keycloak.http.HttpRequest; import org.keycloak.common.ClientConnection; import org.keycloak.common.Profile; @@ -100,7 +101,7 @@ public class AccountRestService { private final KeycloakSession session; private final EventBuilder event; private final Auth auth; - + private final RealmModel realm; private final UserModel user; private final Locale locale; @@ -119,7 +120,7 @@ public class AccountRestService { this.request = session.getContext().getHttpRequest(); this.headers = session.getContext().getRequestHeaders(); } - + /** * Get account information. * @@ -187,7 +188,7 @@ public class AccountRestService { AttributeMetadata am = userProfileAttributes.getMetadata(p.toString()); if(am != null) ret[i++] = am.getAttributeDisplayName(); - else + else ret[i++] = p.toString(); } else { ret[i++] = p.toString(); @@ -230,6 +231,16 @@ public class AccountRestService { return auth.getRealm().getSupportedLocalesStream().collect(Collectors.toList()); } + @Path("/organizations") + public OrganizationsResource organizations() { + checkAccountApiEnabled(); + if (!Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + throw new NotFoundException(); + } + auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); + return new OrganizationsResource(session, auth, user); + } + private ClientRepresentation modelToRepresentation(ClientModel model, List inUseClients, List offlineClients, Map consents) { ClientRepresentation representation = new ClientRepresentation(); representation.setClientId(model.getClientId()); @@ -420,7 +431,7 @@ public class AccountRestService { } return consent; } - + @Path("/linked-accounts") public LinkedAccountsResource linkedAccounts() { return new LinkedAccountsResource(session, request, auth, event, user); @@ -482,10 +493,10 @@ public class AccountRestService { } // TODO Logs - + private static void checkAccountApiEnabled() { if (!Profile.isFeatureEnabled(Profile.Feature.ACCOUNT_API)) { throw new NotFoundException(); -} + } } } diff --git a/services/src/main/java/org/keycloak/services/resources/account/OrganizationsResource.java b/services/src/main/java/org/keycloak/services/resources/account/OrganizationsResource.java new file mode 100644 index 00000000000..917ca3fd1f3 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/resources/account/OrganizationsResource.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.services.resources.account; + +import java.util.stream.Collectors; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.keycloak.models.AccountRoles; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationDomainModel; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.UserModel; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.representations.account.OrganizationRepresentation; +import org.keycloak.services.cors.Cors; +import org.keycloak.services.managers.Auth; + +public class OrganizationsResource { + + private final KeycloakSession session; + private final UserModel user; + private final Auth auth; + + public OrganizationsResource(KeycloakSession session, + Auth auth, + UserModel user) { + this.session = session; + this.auth = auth; + this.user = user; + } + + @GET + @Path("/") + @Produces(MediaType.APPLICATION_JSON) + public Response getOrganizations() { + auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); + return Cors.builder().auth() + .allowedOrigins(auth.getToken()) + .add(Response.ok(session.getProvider(OrganizationProvider.class) + .getByMember(user) + .map(this::toRepresentation)) + ); + } + + private OrganizationRepresentation toRepresentation(OrganizationModel model) { + OrganizationRepresentation rep = new OrganizationRepresentation(); + + rep.setId(model.getId()); + rep.setName(model.getName()); + rep.setAlias(model.getAlias()); + rep.setDescription(model.getDescription()); + rep.setEnabled(model.isEnabled()); + rep.setDomains(model.getDomains().map(OrganizationDomainModel::getName).collect(Collectors.toSet())); + + return rep; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/account/OrganizationAccountTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/account/OrganizationAccountTest.java index 2d3da98abee..d0e4e18580a 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/account/OrganizationAccountTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/account/OrganizationAccountTest.java @@ -18,6 +18,7 @@ package org.keycloak.testsuite.organization.account; import java.io.IOException; +import java.util.List; import java.util.SortedSet; import com.fasterxml.jackson.core.type.TypeReference; @@ -33,13 +34,16 @@ import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.broker.provider.util.SimpleHttp.Response; import org.keycloak.common.Profile.Feature; import org.keycloak.representations.account.LinkedAccountRepresentation; +import org.keycloak.representations.account.OrganizationRepresentation; import org.keycloak.representations.idm.ErrorRepresentation; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.broker.util.SimpleHttpDefault; import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest; import org.keycloak.testsuite.util.TokenUtil; +import org.keycloak.testsuite.util.UserBuilder; @EnableFeature(Feature.ORGANIZATION) public class OrganizationAccountTest extends AbstractOrganizationTest { @@ -87,6 +91,29 @@ public class OrganizationAccountTest extends AbstractOrganizationTest { } } + @Test + public void testGetOrganizations() throws Exception { + UserRepresentation member = createUser(); + org.keycloak.representations.idm.OrganizationRepresentation orgA = createOrganization("orga"); + testRealm().organizations().get(orgA.getId()).members().addMember(member.getId()).close(); + org.keycloak.representations.idm.OrganizationRepresentation orgB = createOrganization("orgb"); + testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close(); + + List organizations = getOrganizations(); + Assert.assertEquals(2, organizations.size()); + OrganizationRepresentation organization = organizations.stream() + .filter(o -> orgA.getId().equals(o.getId())) + .findAny() + .orElse(null); + Assert.assertNotNull(organization); + Assert.assertEquals(orgA.getId(), organization.getId()); + Assert.assertEquals(orgA.getAlias(), organization.getAlias()); + Assert.assertEquals(orgA.getName(), organization.getName()); + Assert.assertEquals(orgA.getDescription(), organization.getDescription()); + Assert.assertEquals(orgA.getDomains().size(), organization.getDomains().size()); + Assert.assertTrue(organization.getDomains().containsAll(orgA.getDomains().stream().map(OrganizationDomainRepresentation::getName).toList())); + } + private SortedSet linkedAccountsRep() throws IOException { return SimpleHttpDefault.doGet(getAccountUrl("linked-accounts"), client).auth(tokenUtil.getToken()) .asJson(new TypeReference<>() {}); @@ -103,4 +130,21 @@ public class OrganizationAccountTest extends AbstractOrganizationTest { return null; } + + private List getOrganizations() throws IOException { + return SimpleHttpDefault.doGet(getAccountUrl("organizations"), client).auth(tokenUtil.getToken()) + .asJson(new TypeReference<>() {}); + } + + private UserRepresentation createUser() { + testRealm().users().create(UserBuilder.create() + .username(bc.getUserEmail()) + .email(bc.getUserEmail()) + .password(bc.getUserPassword()) + .enabled(true) + .build()).close(); + UserRepresentation member = testRealm().users().searchByEmail(bc.getUserEmail(), true).get(0); + getCleanup().addUserId(member.getId()); + return member; + } }