Merge pull request #708 from edewit/service-roles

fixing issue
This commit is contained in:
mfrances17 2021-06-22 09:17:11 -04:00 committed by GitHub
commit d90b1842ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 210 additions and 164 deletions

View file

@ -19,7 +19,11 @@ import { convertFormValuesToObject } from "../../util";
import { MapperList } from "../details/MapperList"; import { MapperList } from "../details/MapperList";
import { ScopeForm } from "../details/ScopeForm"; import { ScopeForm } from "../details/ScopeForm";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { RoleMapping, Row } from "../../components/role-mapping/RoleMapping"; import {
mapRoles,
RoleMapping,
Row,
} from "../../components/role-mapping/RoleMapping";
import type { RoleMappingPayload } from "keycloak-admin/lib/defs/roleRepresentation"; import type { RoleMappingPayload } from "keycloak-admin/lib/defs/roleRepresentation";
import { import {
AllClientScopes, AllClientScopes,
@ -61,41 +65,35 @@ export const ClientScopeForm = () => {
); );
const loader = async () => { const loader = async () => {
const assignedRoles = hide const assignedRoles = (
? await adminClient.clientScopes.listRealmScopeMappings({ id }) await adminClient.clientScopes.listRealmScopeMappings({ id })
: await adminClient.clientScopes.listCompositeRealmScopeMappings({ id }); ).map((role) => ({ role }));
const effectiveRoles = (
await adminClient.clientScopes.listCompositeRealmScopeMappings({ id })
).map((role) => ({ role }));
const clients = await adminClient.clients.find(); const clients = await adminClient.clients.find();
const clientRoles = ( const clientRoles = (
await Promise.all( await Promise.all(
clients.map(async (client) => { clients.map(async (client) => {
const clientScope = hide const clientAssignedRoles = (
? await adminClient.clientScopes.listClientScopeMappings({ await adminClient.clientScopes.listClientScopeMappings({
id, id,
client: client.id!, client: client.id!,
}) })
: await adminClient.clientScopes.listCompositeClientScopeMappings({ ).map((role) => ({ role, client }));
id, const clientEffectiveRoles = (
client: client.id!, await adminClient.clientScopes.listCompositeClientScopeMappings({
}); id,
return clientScope.map((scope) => { client: client.id!,
return { })
client, ).map((role) => ({ role, client }));
role: scope, return mapRoles(clientAssignedRoles, clientEffectiveRoles, hide);
};
});
}) })
) )
).flat(); ).flat();
return [ return [...mapRoles(assignedRoles, effectiveRoles, hide), ...clientRoles];
...assignedRoles.map((role) => {
return {
role,
};
}),
...clientRoles,
];
}; };
const save = async (clientScopes: ClientScopeDefaultOptionalType) => { const save = async (clientScopes: ClientScopeDefaultOptionalType) => {

View file

@ -81,6 +81,7 @@ const ClientDetailHeader = ({
subKey="clients:clientsExplain" subKey="clients:clientsExplain"
badge={client.protocol} badge={client.protocol}
divider={false} divider={false}
helpTextKey="clients-help:enableDisable"
dropdownItems={[ dropdownItems={[
<DropdownItem key="download" onClick={() => toggleDownloadDialog()}> <DropdownItem key="download" onClick={() => toggleDownloadDialog()}>
{t("downloadAdapterConfig")} {t("downloadAdapterConfig")}
@ -358,7 +359,7 @@ export const ClientDetails = () => {
eventKey="serviceAccount" eventKey="serviceAccount"
title={<TabTitleText>{t("serviceAccount")}</TabTitleText>} title={<TabTitleText>{t("serviceAccount")}</TabTitleText>}
> >
<ServiceAccount clientId={clientId} /> <ServiceAccount client={client} />
</Tab> </Tab>
)} )}
<Tab <Tab

View file

@ -1,5 +1,6 @@
{ {
"clients-help": { "clients-help": {
"enableDisable": "Disabled clients cannot initiate a login or have obtained access tokens.",
"clientType": "'OpenID connect' allows Clients to verify the identity of the End-User based on the authentication performed by an Authorization Server.'SAML' enables web-based authentication and authorization scenarios including cross-domain single sign-on (SSO) and uses security tokens containing assertions to pass information.", "clientType": "'OpenID connect' allows Clients to verify the identity of the End-User based on the authentication performed by an Authorization Server.'SAML' enables web-based authentication and authorization scenarios including cross-domain single sign-on (SSO) and uses security tokens containing assertions to pass information.",
"serviceAccount": "Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. In terms of OAuth2 specification, this enables support of 'Client Credentials Grant' for this client.", "serviceAccount": "Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. In terms of OAuth2 specification, this enables support of 'Client Credentials Grant' for this client.",
"authentication": "This defines the type of the OIDC client. When it's ON, the OIDC type is set to confidential access type. When it's OFF, it is set to public access type", "authentication": "This defines the type of the OIDC client. When it's ON, the OIDC type is set to confidential access type. When it's OFF, it is set to public access type",

View file

@ -105,7 +105,7 @@
"authenticationFlow": "Authentication flow", "authenticationFlow": "Authentication flow",
"standardFlow": "Standard flow", "standardFlow": "Standard flow",
"directAccess": "Direct access grants", "directAccess": "Direct access grants",
"serviceAccount": "Service accounts", "serviceAccount": "Service accounts roles",
"enableServiceAccount": "Enable service account roles", "enableServiceAccount": "Enable service account roles",
"assignRolesTo": "Assign roles to {{client}} account", "assignRolesTo": "Assign roles to {{client}} account",
"searchByRoleName": "Search by role name", "searchByRoleName": "Search by role name",

View file

@ -1,99 +1,74 @@
import React, { useContext, useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertVariant } from "@patternfly/react-core"; import { AlertVariant } from "@patternfly/react-core";
import type RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; import type UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
import type { RoleMappingPayload } from "keycloak-admin/lib/defs/roleRepresentation"; import type { RoleMappingPayload } from "keycloak-admin/lib/defs/roleRepresentation";
import { useAdminClient } from "../../context/auth/AdminClient"; import type ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { RealmContext } from "../../context/realm-context/RealmContext"; import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts"; import { useAlerts } from "../../components/alert/Alerts";
import { import {
CompositeRole, mapRoles,
RoleMapping, RoleMapping,
Row, Row,
} from "../../components/role-mapping/RoleMapping"; } from "../../components/role-mapping/RoleMapping";
type ServiceAccountProps = { type ServiceAccountProps = {
clientId: string; client: ClientRepresentation;
}; };
export const ServiceAccount = ({ clientId }: ServiceAccountProps) => { export const ServiceAccount = ({ client }: ServiceAccountProps) => {
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const { realm } = useContext(RealmContext);
const { addAlert } = useAlerts(); const { addAlert } = useAlerts();
const [hide, setHide] = useState(false); const [hide, setHide] = useState(false);
const [serviceAccountId, setServiceAccountId] = useState(""); const [serviceAccount, setServiceAccount] = useState<UserRepresentation>();
const [name, setName] = useState("");
useFetch(
() =>
adminClient.clients.getServiceAccountUser({
id: client.id!,
}),
(serviceAccount) => setServiceAccount(serviceAccount),
[]
);
const loader = async () => { const loader = async () => {
const serviceAccount = await adminClient.clients.getServiceAccountUser({ const serviceAccount = await adminClient.clients.getServiceAccountUser({
id: clientId, id: client.id!,
});
setServiceAccountId(serviceAccount.id!);
const effectiveRoles = await adminClient.users.listCompositeRealmRoleMappings(
{ id: serviceAccount.id! }
);
const assignedRoles = await adminClient.users.listRealmRoleMappings({
id: serviceAccount.id!,
}); });
const id = serviceAccount.id!;
const assignedRoles = (
await adminClient.users.listRealmRoleMappings({ id })
).map((role) => ({ role }));
const effectiveRoles = (
await adminClient.users.listCompositeRealmRoleMappings({ id })
).map((role) => ({ role }));
const clients = await adminClient.clients.find(); const clients = await adminClient.clients.find();
setName(clients.find((c) => c.id === clientId)?.clientId!);
const clientRoles = ( const clientRoles = (
await Promise.all( await Promise.all(
clients.map(async (client) => { clients.map(async (client) => {
return { const clientAssignedRoles = (
client, await adminClient.users.listClientRoleMappings({
roles: await adminClient.users.listClientRoleMappings({ id,
id: serviceAccount.id!,
clientUniqueId: client.id!, clientUniqueId: client.id!,
}), })
}; ).map((role) => ({ role, client }));
const clientEffectiveRoles = (
await adminClient.users.listCompositeClientRoleMappings({
id,
clientUniqueId: client.id!,
})
).map((role) => ({ role, client }));
return mapRoles(clientAssignedRoles, clientEffectiveRoles, hide);
}) })
) )
).filter((rows) => rows.roles.length > 0); ).flat();
const findClient = (role: RoleRepresentation) => { return [...mapRoles(assignedRoles, effectiveRoles, hide), ...clientRoles];
const row = clientRoles.filter((row) =>
row.roles.find((r) => r.id === role.id)
)[0];
return row ? row.client : undefined;
};
const clientRolesFlat = clientRoles.map((row) => row.roles).flat();
const addInherentData = await (async () =>
Promise.all(
effectiveRoles.map(async (role) => {
const compositeRoles = await adminClient.roles.getCompositeRolesForRealm(
{ realm, id: role.id! }
);
return compositeRoles.length > 0
? compositeRoles.map((r) => {
return { ...r, parent: role };
})
: { ...role, parent: undefined };
})
))();
const uniqueRolesWithParent = addInherentData
.flat()
.filter(
(role, index, array) =>
array.findIndex((r) => r.id === role.id) === index
);
return ([
...(hide ? assignedRoles : uniqueRolesWithParent),
...clientRolesFlat,
] as CompositeRole[])
.sort((r1, r2) => r1.name!.localeCompare(r2.name!))
.map((role) => {
return {
client: findClient(role),
role,
} as Row;
});
}; };
const assignRoles = async (rows: Row[]) => { const assignRoles = async (rows: Row[]) => {
@ -103,7 +78,7 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
.map((row) => row.role as RoleMappingPayload) .map((row) => row.role as RoleMappingPayload)
.flat(); .flat();
adminClient.users.addRealmRoleMappings({ adminClient.users.addRealmRoleMappings({
id: serviceAccountId, id: serviceAccount?.id!,
roles: realmRoles, roles: realmRoles,
}); });
await Promise.all( await Promise.all(
@ -111,7 +86,7 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
.filter((row) => row.client !== undefined) .filter((row) => row.client !== undefined)
.map((row) => .map((row) =>
adminClient.users.addClientRoleMappings({ adminClient.users.addClientRoleMappings({
id: serviceAccountId, id: serviceAccount?.id!,
clientUniqueId: row.client!.id!, clientUniqueId: row.client!.id!,
roles: [row.role as RoleMappingPayload], roles: [row.role as RoleMappingPayload],
}) })
@ -128,13 +103,17 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
} }
}; };
return ( return (
<RoleMapping <>
name={name} {serviceAccount && (
id={serviceAccountId} <RoleMapping
type={"service-account"} name={client.clientId!}
loader={loader} id={serviceAccount.id!}
save={assignRoles} type="service-account"
onHideRolesToggle={() => setHide(!hide)} loader={loader}
/> save={assignRoles}
onHideRolesToggle={() => setHide(!hide)}
/>
)}
</>
); );
}; };

View file

@ -248,60 +248,64 @@ export const AddRoleMappingModal = ({
</Button>, </Button>,
]} ]}
> >
<Select
toggleId="role"
onToggle={() => setSearchToggle(!searchToggle)}
isOpen={searchToggle}
variant={isRadio ? SelectVariant.single : SelectVariant.checkbox}
hasInlineFilter
menuAppendTo="parent"
placeholderText={
<>
<FilterIcon /> {t("filterByOrigin")}
</>
}
isGrouped
onFilter={(evt) => {
const value = evt?.target.value || "";
return createSelectGroup(
clients.filter((client) => client.clientId?.includes(value))
);
}}
selections={selectedClients}
onClear={() => setSelectedClients([])}
onSelect={(_, selection) => {
const client = selection as ClientRole;
if (selectedClients.includes(client)) {
removeClient(client);
} else {
setSelectedClients([...selectedClients, client]);
}
}}
>
{createSelectGroup(clients)}
</Select>
<ToolbarItem variant="chip-group">
<ChipGroup>
{selectedClients.map((client) => (
<Chip
key={`chip-${client.id}`}
onClick={() => {
removeClient(client);
refresh();
}}
>
{client.clientId || t("realmRoles")}
<Badge isRead={true}>{client.numberOfRoles}</Badge>
</Chip>
))}
</ChipGroup>
</ToolbarItem>
<KeycloakDataTable <KeycloakDataTable
key={key} key={key}
onSelect={(rows) => setSelectedRows([...rows])} onSelect={(rows) => setSelectedRows([...rows])}
searchPlaceholderKey="clients:searchByRoleName" searchPlaceholderKey="clients:searchByRoleName"
canSelectAll={false} searchTypeComponent={
<ToolbarItem>
<Select
toggleId="role"
onToggle={() => setSearchToggle(!searchToggle)}
isOpen={searchToggle}
variant={isRadio ? SelectVariant.single : SelectVariant.checkbox}
hasInlineFilter
placeholderText={
<>
<FilterIcon /> {t("filterByOrigin")}
</>
}
isGrouped
onFilter={(evt) => {
const value = evt?.target.value || "";
return createSelectGroup(
clients.filter((client) => client.clientId?.includes(value))
);
}}
selections={selectedClients}
onClear={() => setSelectedClients([])}
onSelect={(_, selection) => {
const client = selection as ClientRole;
if (selectedClients.includes(client)) {
removeClient(client);
} else {
setSelectedClients([...selectedClients, client]);
}
}}
>
{createSelectGroup(clients)}
</Select>
</ToolbarItem>
}
subToolbar={
<ToolbarItem widths={{ default: "100%" }}>
<ChipGroup>
{selectedClients.map((client) => (
<Chip
key={`chip-${client.id}`}
onClick={() => {
removeClient(client);
refresh();
}}
>
{client.clientId || t("realmRoles")}
<Badge isRead={true}>{client.numberOfRoles}</Badge>
</Chip>
))}
</ChipGroup>
</ToolbarItem>
}
canSelectAll
isRadio={isRadio} isRadio={isRadio}
loader={loader} loader={loader}
ariaLabelKey="clients:roles" ariaLabelKey="clients:roles"

View file

@ -23,11 +23,38 @@ import { ListEmptyState } from "../list-empty-state/ListEmptyState";
export type CompositeRole = RoleRepresentation & { export type CompositeRole = RoleRepresentation & {
parent: RoleRepresentation; parent: RoleRepresentation;
isInherited?: boolean;
}; };
export type Row = { export type Row = {
client?: ClientRepresentation; client?: ClientRepresentation;
role: CompositeRole | RoleRepresentation; role: RoleRepresentation | CompositeRole;
};
export const mapRoles = (
assignedRoles: Row[],
effectiveRoles: Row[],
hide: boolean
) => {
return [
...(hide
? assignedRoles.map((row) => ({
...row,
role: {
...row.role,
isInherited: false,
},
}))
: effectiveRoles.map((row) => ({
...row,
role: {
...row.role,
isInherited:
assignedRoles.find((r) => r.role.id === row.role.id) ===
undefined,
},
}))),
];
}; };
export const ServiceRole = ({ role, client }: Row) => ( export const ServiceRole = ({ role, client }: Row) => (
@ -153,10 +180,13 @@ export const RoleMapping = ({
data-testid="assigned-roles" data-testid="assigned-roles"
key={key} key={key}
loader={loader} loader={loader}
canSelectAll={hide} canSelectAll
onSelect={hide ? (rows) => setSelected(rows) : undefined} onSelect={(rows) => setSelected(rows)}
searchPlaceholderKey="clients:searchByName" searchPlaceholderKey="clients:searchByName"
ariaLabelKey="clients:clientScopeList" ariaLabelKey="clients:clientScopeList"
isRowDisabled={(value) =>
(value.role as CompositeRole).isInherited || false
}
toolbarItem={ toolbarItem={
<> <>
<ToolbarItem> <ToolbarItem>
@ -191,6 +221,16 @@ export const RoleMapping = ({
</ToolbarItem> </ToolbarItem>
</> </>
} }
actions={[
{
title: t("unAssignRole"),
onRowClick: async (role) => {
setSelected([role]);
toggleDeleteDialog();
return false;
},
},
]}
columns={[ columns={[
{ {
name: "role.name", name: "role.name",

View file

@ -133,6 +133,7 @@ export type DataListProps<T> = {
actionResolver?: IActionsResolver; actionResolver?: IActionsResolver;
searchTypeComponent?: ReactNode; searchTypeComponent?: ReactNode;
toolbarItem?: ReactNode; toolbarItem?: ReactNode;
subToolbar?: ReactNode;
emptyState?: ReactNode; emptyState?: ReactNode;
icon?: React.ComponentClass<SVGIconProps>; icon?: React.ComponentClass<SVGIconProps>;
isNotCompact?: boolean; isNotCompact?: boolean;
@ -178,6 +179,7 @@ export function KeycloakDataTable<T>({
actionResolver, actionResolver,
searchTypeComponent, searchTypeComponent,
toolbarItem, toolbarItem,
subToolbar,
emptyState, emptyState,
icon, icon,
...props ...props
@ -388,6 +390,7 @@ export function KeycloakDataTable<T>({
inputGroupPlaceholder={t(searchPlaceholderKey || "")} inputGroupPlaceholder={t(searchPlaceholderKey || "")}
searchTypeComponent={searchTypeComponent} searchTypeComponent={searchTypeComponent}
toolbarItem={toolbarItem} toolbarItem={toolbarItem}
subToolbar={subToolbar}
> >
{!loading && data && data.length > 0 && ( {!loading && data && data.length > 0 && (
<DataTable <DataTable

View file

@ -1,4 +1,4 @@
import React from "react"; import React, { FormEvent, ReactNode } from "react";
import { import {
Pagination, Pagination,
ToggleTemplateProps, ToggleTemplateProps,
@ -13,14 +13,15 @@ type TableToolbarProps = {
onNextClick: (page: number) => void; onNextClick: (page: number) => void;
onPreviousClick: (page: number) => void; onPreviousClick: (page: number) => void;
onPerPageSelect: (max: number, first: number) => void; onPerPageSelect: (max: number, first: number) => void;
searchTypeComponent?: React.ReactNode; searchTypeComponent?: ReactNode;
toolbarItem?: React.ReactNode; toolbarItem?: ReactNode;
children: React.ReactNode; subToolbar?: ReactNode;
children: ReactNode;
inputGroupName?: string; inputGroupName?: string;
inputGroupPlaceholder?: string; inputGroupPlaceholder?: string;
inputGroupOnChange?: ( inputGroupOnChange?: (
newInput: string, newInput: string,
event: React.FormEvent<HTMLInputElement> event: FormEvent<HTMLInputElement>
) => void; ) => void;
inputGroupOnEnter?: (value: string) => void; inputGroupOnEnter?: (value: string) => void;
}; };
@ -34,6 +35,7 @@ export const PaginatingTableToolbar = ({
onPerPageSelect, onPerPageSelect,
searchTypeComponent, searchTypeComponent,
toolbarItem, toolbarItem,
subToolbar,
children, children,
inputGroupName, inputGroupName,
inputGroupPlaceholder, inputGroupPlaceholder,
@ -70,6 +72,7 @@ export const PaginatingTableToolbar = ({
)} )}
</> </>
} }
subToolbar={subToolbar}
toolbarItemFooter={ toolbarItemFooter={
count !== 0 ? <ToolbarItem>{pagination("bottom")}</ToolbarItem> : <></> count !== 0 ? <ToolbarItem>{pagination("bottom")}</ToolbarItem> : <></>
} }

View file

@ -1,4 +1,4 @@
import React, { FormEvent, Fragment, ReactNode } from "react"; import React, { FormEvent, Fragment, ReactNode, useState } from "react";
import { import {
Toolbar, Toolbar,
ToolbarContent, ToolbarContent,
@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
type TableToolbarProps = { type TableToolbarProps = {
toolbarItem?: ReactNode; toolbarItem?: ReactNode;
subToolbar?: ReactNode;
toolbarItemFooter?: ReactNode; toolbarItemFooter?: ReactNode;
children: ReactNode; children: ReactNode;
searchTypeComponent?: ReactNode; searchTypeComponent?: ReactNode;
@ -28,6 +29,7 @@ type TableToolbarProps = {
export const TableToolbar = ({ export const TableToolbar = ({
toolbarItem, toolbarItem,
subToolbar,
toolbarItemFooter, toolbarItemFooter,
children, children,
searchTypeComponent, searchTypeComponent,
@ -37,7 +39,7 @@ export const TableToolbar = ({
inputGroupOnEnter, inputGroupOnEnter,
}: TableToolbarProps) => { }: TableToolbarProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchValue, setSearchValue] = React.useState<string>(""); const [searchValue, setSearchValue] = useState<string>("");
const onSearch = () => { const onSearch = () => {
if (searchValue !== "") { if (searchValue !== "") {
@ -99,6 +101,11 @@ export const TableToolbar = ({
{toolbarItem} {toolbarItem}
</ToolbarContent> </ToolbarContent>
</Toolbar> </Toolbar>
{subToolbar && (
<Toolbar>
<ToolbarContent>{subToolbar}</ToolbarContent>
</Toolbar>
)}
<Divider /> <Divider />
{children} {children}
<Toolbar>{toolbarItemFooter}</Toolbar> <Toolbar>{toolbarItemFooter}</Toolbar>

View file

@ -21,6 +21,7 @@ import {
FormattedLink, FormattedLink,
FormattedLinkProps, FormattedLinkProps,
} from "../external-link/FormattedLink"; } from "../external-link/FormattedLink";
import { HelpItem } from "../help-enabler/HelpItem";
export type ViewHeaderProps = { export type ViewHeaderProps = {
titleKey: string; titleKey: string;
@ -36,6 +37,7 @@ export type ViewHeaderProps = {
isEnabled?: boolean; isEnabled?: boolean;
onToggle?: (value: boolean) => void; onToggle?: (value: boolean) => void;
divider?: boolean; divider?: boolean;
helpTextKey?: string;
}; };
export const ViewHeader = ({ export const ViewHeader = ({
@ -51,6 +53,7 @@ export const ViewHeader = ({
isEnabled = true, isEnabled = true,
onToggle, onToggle,
divider = true, divider = true,
helpTextKey,
}: ViewHeaderProps) => { }: ViewHeaderProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { enabled } = useContext(HelpContext); const { enabled } = useContext(HelpContext);
@ -106,6 +109,13 @@ export const ViewHeader = ({
} }
}} }}
/> />
{helpTextKey && (
<HelpItem
helpText={t(helpTextKey)}
forLabel={t("common:enabled")}
forID={`${titleKey}-switch`}
/>
)}
</ToolbarItem> </ToolbarItem>
)} )}
{dropdownItems && ( {dropdownItems && (