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:
parent
d63c0fbd13
commit
776a491989
16 changed files with 385 additions and 43 deletions
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -128,6 +128,7 @@
|
||||||
"isInternationalizationEnabled": ${realm.isInternationalizationEnabled()?c},
|
"isInternationalizationEnabled": ${realm.isInternationalizationEnabled()?c},
|
||||||
"isLinkedAccountsEnabled": ${realm.identityFederationEnabled?c},
|
"isLinkedAccountsEnabled": ${realm.identityFederationEnabled?c},
|
||||||
"isMyResourcesEnabled": ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c},
|
"isMyResourcesEnabled": ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c},
|
||||||
|
"isViewOrganizationsEnabled": ${isViewOrganizationsEnabled?c},
|
||||||
"deleteAccountAllowed": ${deleteAccountAllowed?c},
|
"deleteAccountAllowed": ${deleteAccountAllowed?c},
|
||||||
"updateEmailFeatureEnabled": ${updateEmailFeatureEnabled?c},
|
"updateEmailFeatureEnabled": ${updateEmailFeatureEnabled?c},
|
||||||
"updateEmailActionEnabled": ${updateEmailActionEnabled?c},
|
"updateEmailActionEnabled": ${updateEmailActionEnabled?c},
|
||||||
|
|
|
@ -205,4 +205,12 @@ addressScopeConsentText=Address
|
||||||
phoneScopeConsentText=Phone number
|
phoneScopeConsentText=Phone number
|
||||||
offlineAccessScopeConsentText=Offline Access
|
offlineAccessScopeConsentText=Offline Access
|
||||||
samlRoleListScopeConsentText=My Roles
|
samlRoleListScopeConsentText=My Roles
|
||||||
rolesScopeConsentText=User 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
|
|
@ -18,6 +18,11 @@
|
||||||
"path": "groups",
|
"path": "groups",
|
||||||
"isVisible": "isViewGroupsEnabled"
|
"isVisible": "isViewGroupsEnabled"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "organizations",
|
||||||
|
"path": "organizations",
|
||||||
|
"isVisible": "isViewOrganizationsEnabled"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "resources",
|
"label": "resources",
|
||||||
"path": "resources",
|
"path": "resources",
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
type KeycloakContext,
|
type KeycloakContext,
|
||||||
} from "@keycloak/keycloak-ui-shared";
|
} from "@keycloak/keycloak-ui-shared";
|
||||||
|
|
||||||
|
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
||||||
import { joinPath } from "../utils/joinPath";
|
import { joinPath } from "../utils/joinPath";
|
||||||
import { parseResponse } from "./parse-response";
|
import { parseResponse } from "./parse-response";
|
||||||
import {
|
import {
|
||||||
|
@ -156,3 +157,8 @@ export async function getGroups({ signal, context }: CallOptions) {
|
||||||
});
|
});
|
||||||
return parseResponse<Group[]>(response);
|
return parseResponse<Group[]>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserOrganizations({ signal, context }: CallOptions) {
|
||||||
|
const response = await request("/organizations", context, { signal });
|
||||||
|
return parseResponse<OrganizationRepresentation[]>(response);
|
||||||
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ export type Feature = {
|
||||||
updateEmailFeatureEnabled: boolean;
|
updateEmailFeatureEnabled: boolean;
|
||||||
updateEmailActionEnabled: boolean;
|
updateEmailActionEnabled: boolean;
|
||||||
isViewGroupsEnabled: boolean;
|
isViewGroupsEnabled: boolean;
|
||||||
|
isViewOrganizationsEnabled: boolean;
|
||||||
isOid4VciEnabled: boolean;
|
isOid4VciEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
48
js/apps/account-ui/src/organizations/Organizations.tsx
Normal file
48
js/apps/account-ui/src/organizations/Organizations.tsx
Normal 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;
|
|
@ -2,6 +2,7 @@ import { lazy } from "react";
|
||||||
import type { IndexRouteObject, RouteObject } from "react-router-dom";
|
import type { IndexRouteObject, RouteObject } from "react-router-dom";
|
||||||
|
|
||||||
import { environment } from "./environment";
|
import { environment } from "./environment";
|
||||||
|
import { Organizations } from "./organizations/Organizations";
|
||||||
import { ErrorPage } from "./root/ErrorPage";
|
import { ErrorPage } from "./root/ErrorPage";
|
||||||
import { Root } from "./root/Root";
|
import { Root } from "./root/Root";
|
||||||
|
|
||||||
|
@ -59,6 +60,11 @@ export const PersonalInfoRoute: IndexRouteObject = {
|
||||||
element: <PersonalInfo />,
|
element: <PersonalInfo />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const OrganizationsRoute: RouteObject = {
|
||||||
|
path: "organizations",
|
||||||
|
element: <Organizations />,
|
||||||
|
};
|
||||||
|
|
||||||
export const Oid4VciRoute: RouteObject = {
|
export const Oid4VciRoute: RouteObject = {
|
||||||
path: "oid4vci",
|
path: "oid4vci",
|
||||||
element: <Oid4Vci />,
|
element: <Oid4Vci />,
|
||||||
|
@ -75,6 +81,7 @@ export const RootRoute: RouteObject = {
|
||||||
SigningInRoute,
|
SigningInRoute,
|
||||||
ApplicationsRoute,
|
ApplicationsRoute,
|
||||||
GroupsRoute,
|
GroupsRoute,
|
||||||
|
OrganizationsRoute,
|
||||||
PersonalInfoRoute,
|
PersonalInfoRoute,
|
||||||
ResourcesRoute,
|
ResourcesRoute,
|
||||||
ContentRoute,
|
ContentRoute,
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
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 {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
|
@ -11,10 +15,9 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useAdminClient } from "../admin-client";
|
import { useAdminClient } from "../admin-client";
|
||||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||||
import { ListEmptyState } from "@keycloak/keycloak-ui-shared";
|
|
||||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
import { OrganizationTable } from "./OrganizationTable";
|
import { toEditOrganization } from "../organizations/routes/EditOrganization";
|
||||||
import { toAddOrganization } from "./routes/AddOrganization";
|
import { toAddOrganization } from "./routes/AddOrganization";
|
||||||
|
|
||||||
export default function OrganizationSection() {
|
export default function OrganizationSection() {
|
||||||
|
@ -61,6 +64,18 @@ export default function OrganizationSection() {
|
||||||
<PageSection variant="light" className="pf-v5-u-p-0">
|
<PageSection variant="light" className="pf-v5-u-p-0">
|
||||||
<DeleteConfirm />
|
<DeleteConfirm />
|
||||||
<OrganizationTable
|
<OrganizationTable
|
||||||
|
link={({ organization, children }) => (
|
||||||
|
<Link
|
||||||
|
key={organization.id}
|
||||||
|
to={toEditOrganization({
|
||||||
|
realm,
|
||||||
|
id: organization.id!,
|
||||||
|
tab: "settings",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
key={key}
|
key={key}
|
||||||
loader={loader}
|
loader={loader}
|
||||||
isPaginated
|
isPaginated
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
||||||
import {
|
import {
|
||||||
ListEmptyState,
|
ListEmptyState,
|
||||||
|
OrganizationTable,
|
||||||
useAlerts,
|
useAlerts,
|
||||||
useFetch,
|
useFetch,
|
||||||
} from "@keycloak/keycloak-ui-shared";
|
} from "@keycloak/keycloak-ui-shared";
|
||||||
|
@ -15,11 +16,12 @@ import {
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { useAdminClient } from "../admin-client";
|
import { useAdminClient } from "../admin-client";
|
||||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||||
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
import { OrganizationModal } from "../organizations/OrganizationModal";
|
import { OrganizationModal } from "../organizations/OrganizationModal";
|
||||||
import { OrganizationTable } from "../organizations/OrganizationTable";
|
import { toEditOrganization } from "../organizations/routes/EditOrganization";
|
||||||
import useToggle from "../utils/useToggle";
|
import useToggle from "../utils/useToggle";
|
||||||
import { UserParams } from "./routes/User";
|
import { UserParams } from "./routes/User";
|
||||||
|
|
||||||
|
@ -28,6 +30,7 @@ export const Organizations = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<UserParams>();
|
const { id } = useParams<UserParams>();
|
||||||
const { addAlert, addError } = useAlerts();
|
const { addAlert, addError } = useAlerts();
|
||||||
|
const { realm } = useRealm();
|
||||||
|
|
||||||
const [key, setKey] = useState(0);
|
const [key, setKey] = useState(0);
|
||||||
const refresh = () => setKey(key + 1);
|
const refresh = () => setKey(key + 1);
|
||||||
|
@ -116,6 +119,18 @@ export const Organizations = () => {
|
||||||
)}
|
)}
|
||||||
<DeleteConfirm />
|
<DeleteConfirm />
|
||||||
<OrganizationTable
|
<OrganizationTable
|
||||||
|
link={({ organization, children }) => (
|
||||||
|
<Link
|
||||||
|
key={organization.id}
|
||||||
|
to={toEditOrganization({
|
||||||
|
realm,
|
||||||
|
id: organization.id!,
|
||||||
|
tab: "settings",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
loader={userOrgs}
|
loader={userOrgs}
|
||||||
onSelect={(orgs) => setSelectedOrgs(orgs)}
|
onSelect={(orgs) => setSelectedOrgs(orgs)}
|
||||||
deleteLabel="remove"
|
deleteLabel="remove"
|
||||||
|
|
|
@ -1,29 +1,23 @@
|
||||||
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
||||||
import { Badge, Chip, ChipGroup } from "@patternfly/react-core";
|
import { Badge, Chip, ChipGroup } from "@patternfly/react-core";
|
||||||
import { TableText } from "@patternfly/react-table";
|
import { TableText } from "@patternfly/react-table";
|
||||||
import { PropsWithChildren, ReactNode } from "react";
|
import { FunctionComponent, PropsWithChildren, ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { KeycloakDataTable, LoaderFunction } from "./table/KeycloakDataTable";
|
||||||
import {
|
|
||||||
KeycloakDataTable,
|
|
||||||
LoaderFunction,
|
|
||||||
} from "@keycloak/keycloak-ui-shared";
|
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
|
||||||
import { toEditOrganization } from "./routes/EditOrganization";
|
|
||||||
|
|
||||||
const OrgDetailLink = (organization: OrganizationRepresentation) => {
|
type OrgDetailLinkProps = {
|
||||||
|
link: FunctionComponent<
|
||||||
|
PropsWithChildren<{ organization: OrganizationRepresentation }>
|
||||||
|
>;
|
||||||
|
organization: OrganizationRepresentation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrgDetailLink = ({ link, organization }: OrgDetailLinkProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { realm } = useRealm();
|
const Component = link;
|
||||||
return (
|
return (
|
||||||
<TableText wrapModifier="truncate">
|
<TableText wrapModifier="truncate">
|
||||||
<Link
|
<Component organization={organization}>
|
||||||
key={organization.id}
|
|
||||||
to={toEditOrganization({
|
|
||||||
realm,
|
|
||||||
id: organization.id!,
|
|
||||||
tab: "settings",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{organization.name}
|
{organization.name}
|
||||||
{!organization.enabled && (
|
{!organization.enabled && (
|
||||||
<Badge
|
<Badge
|
||||||
|
@ -34,7 +28,7 @@ const OrgDetailLink = (organization: OrganizationRepresentation) => {
|
||||||
{t("disabled")}
|
{t("disabled")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Component>
|
||||||
</TableText>
|
</TableText>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -47,11 +41,14 @@ const Domains = (org: OrganizationRepresentation) => {
|
||||||
expandedText={t("hide")}
|
expandedText={t("hide")}
|
||||||
collapsedText={t("showRemaining")}
|
collapsedText={t("showRemaining")}
|
||||||
>
|
>
|
||||||
{org.domains?.map((dn) => (
|
{org.domains?.map((dn) => {
|
||||||
<Chip key={dn.name} isReadOnly>
|
const name = typeof dn === "string" ? dn : dn.name;
|
||||||
{dn.name}
|
return (
|
||||||
</Chip>
|
<Chip key={name} isReadOnly>
|
||||||
))}
|
{name}
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ChipGroup>
|
</ChipGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -60,6 +57,9 @@ type OrganizationTableProps = PropsWithChildren & {
|
||||||
loader:
|
loader:
|
||||||
| LoaderFunction<OrganizationRepresentation>
|
| LoaderFunction<OrganizationRepresentation>
|
||||||
| OrganizationRepresentation[];
|
| OrganizationRepresentation[];
|
||||||
|
link: FunctionComponent<
|
||||||
|
PropsWithChildren<{ organization: OrganizationRepresentation }>
|
||||||
|
>;
|
||||||
toolbarItem?: ReactNode;
|
toolbarItem?: ReactNode;
|
||||||
isPaginated?: boolean;
|
isPaginated?: boolean;
|
||||||
onSelect?: (orgs: OrganizationRepresentation[]) => void;
|
onSelect?: (orgs: OrganizationRepresentation[]) => void;
|
||||||
|
@ -74,6 +74,7 @@ export const OrganizationTable = ({
|
||||||
onSelect,
|
onSelect,
|
||||||
onDelete,
|
onDelete,
|
||||||
deleteLabel = "delete",
|
deleteLabel = "delete",
|
||||||
|
link,
|
||||||
children,
|
children,
|
||||||
}: OrganizationTableProps) => {
|
}: OrganizationTableProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -87,17 +88,23 @@ export const OrganizationTable = ({
|
||||||
toolbarItem={toolbarItem}
|
toolbarItem={toolbarItem}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
canSelectAll={onSelect !== undefined}
|
canSelectAll={onSelect !== undefined}
|
||||||
actions={[
|
actions={
|
||||||
{
|
onDelete
|
||||||
title: t(deleteLabel),
|
? [
|
||||||
onRowClick: onDelete,
|
{
|
||||||
},
|
title: t(deleteLabel),
|
||||||
]}
|
onRowClick: onDelete,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
name: "name",
|
name: "name",
|
||||||
displayKey: "name",
|
displayKey: "name",
|
||||||
cellRenderer: OrgDetailLink,
|
cellRenderer: (row) => (
|
||||||
|
<OrgDetailLink link={link} organization={row} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "domains",
|
name: "domains",
|
|
@ -92,3 +92,4 @@ export {
|
||||||
ErrorBoundaryProvider,
|
ErrorBoundaryProvider,
|
||||||
} from "./utils/ErrorBoundary";
|
} from "./utils/ErrorBoundary";
|
||||||
export type { FallbackProps } from "./utils/ErrorBoundary";
|
export type { FallbackProps } from "./utils/ErrorBoundary";
|
||||||
|
export { OrganizationTable } from "./controls/OrganizationTable";
|
||||||
|
|
|
@ -160,6 +160,7 @@ public class AccountConsole implements AccountResourceProvider {
|
||||||
map.put("deleteAccountAllowed", deleteAccountAllowed);
|
map.put("deleteAccountAllowed", deleteAccountAllowed);
|
||||||
|
|
||||||
map.put("isViewGroupsEnabled", isViewGroupsEnabled);
|
map.put("isViewGroupsEnabled", isViewGroupsEnabled);
|
||||||
|
map.put("isViewOrganizationsEnabled", Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION));
|
||||||
map.put("isOid4VciEnabled", Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI));
|
map.put("isOid4VciEnabled", Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI));
|
||||||
|
|
||||||
map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL));
|
map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL));
|
||||||
|
|
|
@ -46,6 +46,7 @@ import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
import org.jboss.resteasy.reactive.NoCache;
|
import org.jboss.resteasy.reactive.NoCache;
|
||||||
|
import org.keycloak.common.Profile.Feature;
|
||||||
import org.keycloak.http.HttpRequest;
|
import org.keycloak.http.HttpRequest;
|
||||||
import org.keycloak.common.ClientConnection;
|
import org.keycloak.common.ClientConnection;
|
||||||
import org.keycloak.common.Profile;
|
import org.keycloak.common.Profile;
|
||||||
|
@ -100,7 +101,7 @@ public class AccountRestService {
|
||||||
private final KeycloakSession session;
|
private final KeycloakSession session;
|
||||||
private final EventBuilder event;
|
private final EventBuilder event;
|
||||||
private final Auth auth;
|
private final Auth auth;
|
||||||
|
|
||||||
private final RealmModel realm;
|
private final RealmModel realm;
|
||||||
private final UserModel user;
|
private final UserModel user;
|
||||||
private final Locale locale;
|
private final Locale locale;
|
||||||
|
@ -119,7 +120,7 @@ public class AccountRestService {
|
||||||
this.request = session.getContext().getHttpRequest();
|
this.request = session.getContext().getHttpRequest();
|
||||||
this.headers = session.getContext().getRequestHeaders();
|
this.headers = session.getContext().getRequestHeaders();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get account information.
|
* Get account information.
|
||||||
*
|
*
|
||||||
|
@ -187,7 +188,7 @@ public class AccountRestService {
|
||||||
AttributeMetadata am = userProfileAttributes.getMetadata(p.toString());
|
AttributeMetadata am = userProfileAttributes.getMetadata(p.toString());
|
||||||
if(am != null)
|
if(am != null)
|
||||||
ret[i++] = am.getAttributeDisplayName();
|
ret[i++] = am.getAttributeDisplayName();
|
||||||
else
|
else
|
||||||
ret[i++] = p.toString();
|
ret[i++] = p.toString();
|
||||||
} else {
|
} else {
|
||||||
ret[i++] = p.toString();
|
ret[i++] = p.toString();
|
||||||
|
@ -230,6 +231,16 @@ public class AccountRestService {
|
||||||
return auth.getRealm().getSupportedLocalesStream().collect(Collectors.toList());
|
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) {
|
private ClientRepresentation modelToRepresentation(ClientModel model, List<String> inUseClients, List<String> offlineClients, Map<String, UserConsentModel> consents) {
|
||||||
ClientRepresentation representation = new ClientRepresentation();
|
ClientRepresentation representation = new ClientRepresentation();
|
||||||
representation.setClientId(model.getClientId());
|
representation.setClientId(model.getClientId());
|
||||||
|
@ -420,7 +431,7 @@ public class AccountRestService {
|
||||||
}
|
}
|
||||||
return consent;
|
return consent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Path("/linked-accounts")
|
@Path("/linked-accounts")
|
||||||
public LinkedAccountsResource linkedAccounts() {
|
public LinkedAccountsResource linkedAccounts() {
|
||||||
return new LinkedAccountsResource(session, request, auth, event, user);
|
return new LinkedAccountsResource(session, request, auth, event, user);
|
||||||
|
@ -482,10 +493,10 @@ public class AccountRestService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Logs
|
// TODO Logs
|
||||||
|
|
||||||
private static void checkAccountApiEnabled() {
|
private static void checkAccountApiEnabled() {
|
||||||
if (!Profile.isFeatureEnabled(Profile.Feature.ACCOUNT_API)) {
|
if (!Profile.isFeatureEnabled(Profile.Feature.ACCOUNT_API)) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,7 @@
|
||||||
package org.keycloak.testsuite.organization.account;
|
package org.keycloak.testsuite.organization.account;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
import java.util.SortedSet;
|
import java.util.SortedSet;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
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.broker.provider.util.SimpleHttp.Response;
|
||||||
import org.keycloak.common.Profile.Feature;
|
import org.keycloak.common.Profile.Feature;
|
||||||
import org.keycloak.representations.account.LinkedAccountRepresentation;
|
import org.keycloak.representations.account.LinkedAccountRepresentation;
|
||||||
|
import org.keycloak.representations.account.OrganizationRepresentation;
|
||||||
import org.keycloak.representations.idm.ErrorRepresentation;
|
import org.keycloak.representations.idm.ErrorRepresentation;
|
||||||
|
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||||
import org.keycloak.representations.idm.UserRepresentation;
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
import org.keycloak.testsuite.admin.ApiUtil;
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||||
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
|
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
|
||||||
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
|
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
|
||||||
import org.keycloak.testsuite.util.TokenUtil;
|
import org.keycloak.testsuite.util.TokenUtil;
|
||||||
|
import org.keycloak.testsuite.util.UserBuilder;
|
||||||
|
|
||||||
@EnableFeature(Feature.ORGANIZATION)
|
@EnableFeature(Feature.ORGANIZATION)
|
||||||
public class OrganizationAccountTest extends AbstractOrganizationTest {
|
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 {
|
private SortedSet<LinkedAccountRepresentation> linkedAccountsRep() throws IOException {
|
||||||
return SimpleHttpDefault.doGet(getAccountUrl("linked-accounts"), client).auth(tokenUtil.getToken())
|
return SimpleHttpDefault.doGet(getAccountUrl("linked-accounts"), client).auth(tokenUtil.getToken())
|
||||||
.asJson(new TypeReference<>() {});
|
.asJson(new TypeReference<>() {});
|
||||||
|
@ -103,4 +130,21 @@ public class OrganizationAccountTest extends AbstractOrganizationTest {
|
||||||
|
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue