added organizations table to account (#32311)

* added organizations table to account

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
Co-authored-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Erik Jan de Wit 2024-08-22 20:44:03 +02:00 committed by GitHub
parent d63c0fbd13
commit 776a491989
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 385 additions and 43 deletions

View file

@ -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<String> 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<String> getDomains() {
return domains;
}
public void setDomains(Set<String> 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();
}
}

View file

@ -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},

View file

@ -206,3 +206,11 @@ phoneScopeConsentText=Phone number
offlineAccessScopeConsentText=Offline Access
samlRoleListScopeConsentText=My Roles
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

View file

@ -18,6 +18,11 @@
"path": "groups",
"isVisible": "isViewGroupsEnabled"
},
{
"label": "organizations",
"path": "organizations",
"isVisible": "isViewOrganizationsEnabled"
},
{
"label": "resources",
"path": "resources",

View file

@ -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<Group[]>(response);
}
export async function getUserOrganizations({ signal, context }: CallOptions) {
const response = await request("/organizations", context, { signal });
return parseResponse<OrganizationRepresentation[]>(response);
}

View file

@ -25,6 +25,7 @@ export type Feature = {
updateEmailFeatureEnabled: boolean;
updateEmailActionEnabled: boolean;
isViewGroupsEnabled: boolean;
isViewOrganizationsEnabled: boolean;
isOid4VciEnabled: boolean;
};

View file

@ -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<Environment>();
const [userOrgs, setUserOrgs] = useState<OrganizationRepresentation[]>([]);
usePromise(
(signal) => getUserOrganizations({ signal, context }),
setUserOrgs,
);
if (!userOrgs) {
return <KeycloakSpinner />;
}
return (
<Page title={t("organizations")} description={t("organizationDescription")}>
<ErrorBoundaryProvider>
<OrganizationTable
link={({ children }) => <span>{children}</span>}
loader={userOrgs}
>
<ListEmptyState
message={t("emptyUserOrganizations")}
instructions={t("emptyUserOrganizationsInstructions")}
/>
</OrganizationTable>
</ErrorBoundaryProvider>
</Page>
);
};
export default Organizations;

View file

@ -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: <PersonalInfo />,
};
export const OrganizationsRoute: RouteObject = {
path: "organizations",
element: <Organizations />,
};
export const Oid4VciRoute: RouteObject = {
path: "oid4vci",
element: <Oid4Vci />,
@ -75,6 +81,7 @@ export const RootRoute: RouteObject = {
SigningInRoute,
ApplicationsRoute,
GroupsRoute,
OrganizationsRoute,
PersonalInfoRoute,
ResourcesRoute,
ContentRoute,

View file

@ -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() {
<PageSection variant="light" className="pf-v5-u-p-0">
<DeleteConfirm />
<OrganizationTable
link={({ organization, children }) => (
<Link
key={organization.id}
to={toEditOrganization({
realm,
id: organization.id!,
tab: "settings",
})}
>
{children}
</Link>
)}
key={key}
loader={loader}
isPaginated

View file

@ -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<UserParams>();
const { addAlert, addError } = useAlerts();
const { realm } = useRealm();
const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1);
@ -116,6 +119,18 @@ export const Organizations = () => {
)}
<DeleteConfirm />
<OrganizationTable
link={({ organization, children }) => (
<Link
key={organization.id}
to={toEditOrganization({
realm,
id: organization.id!,
tab: "settings",
})}
>
{children}
</Link>
)}
loader={userOrgs}
onSelect={(orgs) => setSelectedOrgs(orgs)}
deleteLabel="remove"

View file

@ -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 (
<TableText wrapModifier="truncate">
<Link
key={organization.id}
to={toEditOrganization({
realm,
id: organization.id!,
tab: "settings",
})}
>
<Component organization={organization}>
{organization.name}
{!organization.enabled && (
<Badge
@ -34,7 +28,7 @@ const OrgDetailLink = (organization: OrganizationRepresentation) => {
{t("disabled")}
</Badge>
)}
</Link>
</Component>
</TableText>
);
};
@ -47,11 +41,14 @@ const Domains = (org: OrganizationRepresentation) => {
expandedText={t("hide")}
collapsedText={t("showRemaining")}
>
{org.domains?.map((dn) => (
<Chip key={dn.name} isReadOnly>
{dn.name}
{org.domains?.map((dn) => {
const name = typeof dn === "string" ? dn : dn.name;
return (
<Chip key={name} isReadOnly>
{name}
</Chip>
))}
);
})}
</ChipGroup>
);
};
@ -60,6 +57,9 @@ type OrganizationTableProps = PropsWithChildren & {
loader:
| LoaderFunction<OrganizationRepresentation>
| 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={[
actions={
onDelete
? [
{
title: t(deleteLabel),
onRowClick: onDelete,
},
]}
]
: undefined
}
columns={[
{
name: "name",
displayKey: "name",
cellRenderer: OrgDetailLink,
cellRenderer: (row) => (
<OrgDetailLink link={link} organization={row} />
),
},
{
name: "domains",

View file

@ -92,3 +92,4 @@ export {
ErrorBoundaryProvider,
} from "./utils/ErrorBoundary";
export type { FallbackProps } from "./utils/ErrorBoundary";
export { OrganizationTable } from "./controls/OrganizationTable";

View file

@ -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));

View file

@ -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;
@ -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<String> inUseClients, List<String> offlineClients, Map<String, UserConsentModel> consents) {
ClientRepresentation representation = new ClientRepresentation();
representation.setClientId(model.getClientId());

View file

@ -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;
}
}

View file

@ -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<OrganizationRepresentation> 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<LinkedAccountRepresentation> 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<OrganizationRepresentation> 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;
}
}