Created add service account roles assign screen (#465)

* service account dialog

* create test

* fixed types

* fixed realm roles selection

* disable when no rows are selected

Co-authored-by: Eugenia <32821331+jenny-s51@users.noreply.github.com>

Co-authored-by: Eugenia <32821331+jenny-s51@users.noreply.github.com>
This commit is contained in:
Erik Jan de Wit 2021-04-01 16:14:19 +02:00 committed by GitHub
parent a0faba0f97
commit 84bf7925a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 814 additions and 380 deletions

View file

@ -8,6 +8,7 @@ import AdvancedTab from "../support/pages/admin_console/manage/clients/AdvancedT
import AdminClient from "../support/util/AdminClient";
import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients/InitialAccessTokenTab";
import { keycloakBefore } from "../support/util/keycloak_before";
import ServiceAccountTab from "../support/pages/admin_console/manage/clients/ServiceAccountTab";
let itemId = "client_crud";
const loginPage = new LoginPage();
@ -162,4 +163,39 @@ describe("Clients test", function () {
advancedTab.checkAccessTokenSignatureAlgorithm(algorithm);
});
});
describe("Service account tab test", () => {
const serviceAccountTab = new ServiceAccountTab();
const serviceAccountName = "service-account-client";
beforeEach(() => {
keycloakBefore();
loginPage.logIn();
sidebarPage.goToClients();
});
before(async () => {
await new AdminClient().createClient({
protocol: "openid-connect",
clientId: serviceAccountName,
publicClient: false,
authorizationServicesEnabled: true,
serviceAccountsEnabled: true,
standardFlowEnabled: true,
});
});
after(() => {
new AdminClient().deleteClient(serviceAccountName);
});
it("list", () => {
listingPage
.searchItem(serviceAccountName)
.goToItemDetails(serviceAccountName);
serviceAccountTab
.goToTab()
.checkRoles(["manage-account", "offline_access", "uma_authorization"]);
});
});
});

View file

@ -64,6 +64,13 @@ export default class CreateClientPage {
return this;
}
changeSwitches(switches: string[]) {
for (const uiSwitch of switches) {
cy.getId(uiSwitch).check({ force: true });
}
return this;
}
checkClientTypeRequiredMessage(exist = true) {
cy.get(this.clientTypeError).should((!exist ? "not." : "") + "exist");

View file

@ -0,0 +1,23 @@
const expect = chai.expect;
export default class ServiceAccountTab {
private tab = "#pf-tab-serviceAccount-serviceAccount";
private assignedRolesTable = "assigned-roles";
private namesColumn = 'td[data-label="Name"]:visible';
goToTab() {
cy.get(this.tab).click();
return this;
}
checkRoles(roleNames: string[]) {
cy.getId(this.assignedRolesTable)
.get(this.namesColumn)
.should((roles) => {
for (let index = 0; index < roleNames.length; index++) {
const roleName = roleNames[index];
expect(roles).to.contain(roleName);
}
});
return this;
}
}

View file

@ -1,5 +1,6 @@
import KeycloakAdminClient from "keycloak-admin";
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
export default class AdminClient {
private client: KeycloakAdminClient;
@ -24,6 +25,10 @@ export default class AdminClient {
await this.client.realms.del({ realm });
}
async createClient(client: ClientRepresentation) {
await this.login();
await this.client.clients.create(client);
}
async deleteClient(clientName: string) {
await this.login();
const client = (

View file

@ -10,6 +10,7 @@ import {
ButtonVariant,
ExpandableSection,
FormGroup,
PageSection,
Split,
SplitItem,
Text,
@ -176,257 +177,261 @@ export const AdvancedTab = ({
}
return (
<ScrollForm sections={sections}>
<>
<Text className="pf-u-py-lg">
<Trans i18nKey="clients-help:notBeforeIntro">
In order to successfully push setup url on
<Link to={`/${realm}/clients/${id}/settings`}>{t("settings")}</Link>
tab
</Trans>
</Text>
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
label={t("notBefore")}
fieldId="kc-not-before"
labelIcon={
<HelpItem
helpText="clients-help:notBefore"
forLabel={t("notBefore")}
forID="kc-not-before"
/>
}
>
<TextInput
type="text"
id="kc-not-before"
name="notBefore"
isReadOnly
value={formatDate()}
/>
</FormGroup>
<ActionGroup>
<Button
id="setToNow"
variant="tertiary"
onClick={() => {
setNotBefore(moment.now() / 1000, "notBeforeSetToNow");
}}
>
{t("setToNow")}
</Button>
<Button
id="clear"
variant="tertiary"
onClick={() => {
setNotBefore(0, "notBeforeNowClear");
}}
>
{t("clear")}
</Button>
<Button id="push" variant="secondary" onClick={push}>
{t("push")}
</Button>
</ActionGroup>
</FormAccess>
</>
<>
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
label={t("nodeReRegistrationTimeout")}
fieldId="kc-node-reregistration-timeout"
labelIcon={
<HelpItem
helpText="clients-help:nodeReRegistrationTimeout"
forLabel={t("nodeReRegistrationTimeout")}
forID="nodeReRegistrationTimeout"
/>
}
>
<Split hasGutter>
<SplitItem>
<Controller
name="nodeReRegistrationTimeout"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<TimeSelector value={value} onChange={onChange} />
)}
/>
</SplitItem>
<SplitItem>
<Button
variant={ButtonVariant.secondary}
onClick={() => save()}
>
{t("common:save")}
</Button>
</SplitItem>
</Split>
</FormGroup>
</FormAccess>
<PageSection variant="light">
<ScrollForm sections={sections}>
<>
<DeleteNodeConfirm />
<AddHostDialog
clientId={id!}
isOpen={addNodeOpen}
onAdded={(node) => {
nodes[node] = moment.now() / 1000;
refresh();
}}
onClose={() => setAddNodeOpen(false)}
/>
<ExpandableSection
toggleText={t("registeredClusterNodes")}
onToggle={() => setExpanded(!expanded)}
isExpanded={expanded}
>
<KeycloakDataTable
key={key}
ariaLabelKey="registeredClusterNodes"
loader={() =>
Promise.resolve(
Object.entries(nodes || {}).map((entry) => {
return { host: entry[0], registration: entry[1] };
})
)
<Text className="pf-u-py-lg">
<Trans i18nKey="clients-help:notBeforeIntro">
In order to successfully push setup url on
<Link to={`/${realm}/clients/${id}/settings`}>
{t("settings")}
</Link>
tab
</Trans>
</Text>
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
label={t("notBefore")}
fieldId="kc-not-before"
labelIcon={
<HelpItem
helpText="clients-help:notBefore"
forLabel={t("notBefore")}
forID="kc-not-before"
/>
}
toolbarItem={
<>
<ToolbarItem>
<Button
id="testClusterAvailability"
onClick={testCluster}
variant={ButtonVariant.secondary}
isDisabled={Object.keys(nodes).length === 0}
>
{t("testClusterAvailability")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
id="registerNodeManually"
onClick={() => setAddNodeOpen(true)}
variant={ButtonVariant.tertiary}
>
{t("registerNodeManually")}
</Button>
</ToolbarItem>
</>
}
actions={[
{
title: t("common:delete"),
onRowClick: (node) => {
setSelectedNode(node.host);
toggleDeleteNodeConfirm();
},
},
]}
columns={[
{
name: "host",
displayKey: "clients:nodeHost",
},
{
name: "registration",
displayKey: "clients:lastRegistration",
cellFormatters: [
(value) =>
value
? moment(parseInt(value.toString()) * 1000).format(
"LLL"
)
: "",
],
},
]}
/>
</ExpandableSection>
>
<TextInput
type="text"
id="kc-not-before"
name="notBefore"
isReadOnly
value={formatDate()}
/>
</FormGroup>
<ActionGroup>
<Button
id="setToNow"
variant="tertiary"
onClick={() => {
setNotBefore(moment.now() / 1000, "notBeforeSetToNow");
}}
>
{t("setToNow")}
</Button>
<Button
id="clear"
variant="tertiary"
onClick={() => {
setNotBefore(0, "notBeforeNowClear");
}}
>
{t("clear")}
</Button>
<Button id="push" variant="secondary" onClick={push}>
{t("push")}
</Button>
</ActionGroup>
</FormAccess>
</>
<>
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
label={t("nodeReRegistrationTimeout")}
fieldId="kc-node-reregistration-timeout"
labelIcon={
<HelpItem
helpText="clients-help:nodeReRegistrationTimeout"
forLabel={t("nodeReRegistrationTimeout")}
forID="nodeReRegistrationTimeout"
/>
}
>
<Split hasGutter>
<SplitItem>
<Controller
name="nodeReRegistrationTimeout"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<TimeSelector value={value} onChange={onChange} />
)}
/>
</SplitItem>
<SplitItem>
<Button
variant={ButtonVariant.secondary}
onClick={() => save()}
>
{t("common:save")}
</Button>
</SplitItem>
</Split>
</FormGroup>
</FormAccess>
<>
<DeleteNodeConfirm />
<AddHostDialog
clientId={id!}
isOpen={addNodeOpen}
onAdded={(node) => {
nodes[node] = moment.now() / 1000;
refresh();
}}
onClose={() => setAddNodeOpen(false)}
/>
<ExpandableSection
toggleText={t("registeredClusterNodes")}
onToggle={() => setExpanded(!expanded)}
isExpanded={expanded}
>
<KeycloakDataTable
key={key}
ariaLabelKey="registeredClusterNodes"
loader={() =>
Promise.resolve(
Object.entries(nodes || {}).map((entry) => {
return { host: entry[0], registration: entry[1] };
})
)
}
toolbarItem={
<>
<ToolbarItem>
<Button
id="testClusterAvailability"
onClick={testCluster}
variant={ButtonVariant.secondary}
isDisabled={Object.keys(nodes).length === 0}
>
{t("testClusterAvailability")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
id="registerNodeManually"
onClick={() => setAddNodeOpen(true)}
variant={ButtonVariant.tertiary}
>
{t("registerNodeManually")}
</Button>
</ToolbarItem>
</>
}
actions={[
{
title: t("common:delete"),
onRowClick: (node) => {
setSelectedNode(node.host);
toggleDeleteNodeConfirm();
},
},
]}
columns={[
{
name: "host",
displayKey: "clients:nodeHost",
},
{
name: "registration",
displayKey: "clients:lastRegistration",
cellFormatters: [
(value) =>
value
? moment(parseInt(value.toString()) * 1000).format(
"LLL"
)
: "",
],
},
]}
/>
</ExpandableSection>
</>
</>
<>
{protocol === openIdConnect && (
<>
<Text className="pf-u-py-lg">
{t("clients-help:fineGrainOpenIdConnectConfiguration")}
</Text>
<FineGrainOpenIdConnect
control={control}
save={() => save()}
reset={() =>
convertToFormValues(attributes, "attributes", setValue)
}
/>
</>
)}
{protocol !== openIdConnect && (
<>
<Text className="pf-u-py-lg">
{t("clients-help:fineGrainSamlEndpointConfig")}
</Text>
<FineGrainSamlEndpointConfig
control={control}
save={() => save()}
reset={() =>
convertToFormValues(attributes, "attributes", setValue)
}
/>
</>
)}
</>
</>
<>
{protocol === openIdConnect && (
<>
<Text className="pf-u-py-lg">
{t("clients-help:fineGrainOpenIdConnectConfiguration")}
{t("clients-help:openIdConnectCompatibilityModes")}
</Text>
<FineGrainOpenIdConnect
<OpenIdConnectCompatibilityModes
control={control}
save={() => save()}
reset={() =>
convertToFormValues(attributes, "attributes", setValue)
resetFields(["exclude-session-state-from-auth-response"])
}
/>
</>
)}
{protocol !== openIdConnect && (
<>
<Text className="pf-u-py-lg">
{t("clients-help:fineGrainSamlEndpointConfig")}
</Text>
<FineGrainSamlEndpointConfig
control={control}
save={() => save()}
reset={() =>
convertToFormValues(attributes, "attributes", setValue)
}
/>
</>
)}
</>
{protocol === openIdConnect && (
<>
<Text className="pf-u-py-lg">
{t("clients-help:openIdConnectCompatibilityModes")}
{t("clients-help:advancedSettings" + toUpperCase(protocol!))}
</Text>
<OpenIdConnectCompatibilityModes
<AdvancedSettings
protocol={protocol}
control={control}
save={() => save()}
reset={() =>
resetFields(["exclude-session-state-from-auth-response"])
}
reset={() => {
resetFields([
"saml-assertion-lifespan",
"access-token-lifespan",
"tls-client-certificate-bound-access-tokens",
"pkce-code-challenge-method",
]);
}}
/>
</>
)}
<>
<Text className="pf-u-py-lg">
{t("clients-help:advancedSettings" + toUpperCase(protocol!))}
</Text>
<AdvancedSettings
protocol={protocol}
control={control}
save={() => save()}
reset={() => {
resetFields([
"saml-assertion-lifespan",
"access-token-lifespan",
"tls-client-certificate-bound-access-tokens",
"pkce-code-challenge-method",
]);
}}
/>
</>
<>
<Text className="pf-u-py-lg">
{t("clients-help:authenticationOverrides")}
</Text>
<AuthenticationOverrides
protocol={protocol}
control={control}
save={() => save()}
reset={() => {
setValue(
"authenticationFlowBindingOverrides.browser",
authenticationFlowBindingOverrides?.browser
);
setValue(
"authenticationFlowBindingOverrides.direct_grant",
authenticationFlowBindingOverrides?.direct_grant
);
}}
/>
</>
</ScrollForm>
<>
<Text className="pf-u-py-lg">
{t("clients-help:authenticationOverrides")}
</Text>
<AuthenticationOverrides
protocol={protocol}
control={control}
save={() => save()}
reset={() => {
setValue(
"authenticationFlowBindingOverrides.browser",
authenticationFlowBindingOverrides?.browser
);
setValue(
"authenticationFlowBindingOverrides.direct_grant",
authenticationFlowBindingOverrides?.direct_grant
);
}}
/>
</>
</ScrollForm>
</PageSection>
);
};

View file

@ -44,6 +44,7 @@ export const CapabilityConfig = ({
control={control}
render={({ onChange, value }) => (
<Switch
data-testid="authentication"
id="kc-authentication"
name="publicClient"
label={t("common:on")}
@ -65,6 +66,7 @@ export const CapabilityConfig = ({
control={control}
render={({ onChange, value }) => (
<Switch
data-testid="authorization"
id="kc-authorization"
name="authorizationServicesEnabled"
label={t("common:on")}
@ -95,6 +97,7 @@ export const CapabilityConfig = ({
render={({ onChange, value }) => (
<InputGroup>
<Checkbox
data-testid="standard"
label={t("standardFlow")}
id="kc-flow-standard"
name="standardFlowEnabled"
@ -118,6 +121,7 @@ export const CapabilityConfig = ({
render={({ onChange, value }) => (
<InputGroup>
<Checkbox
data-testid="direct"
label={t("directAccess")}
id="kc-flow-direct"
name="directAccessGrantsEnabled"
@ -141,6 +145,7 @@ export const CapabilityConfig = ({
render={({ onChange, value }) => (
<InputGroup>
<Checkbox
data-testid="implicit"
label={t("implicitFlow")}
id="kc-flow-implicit"
name="implicitFlowEnabled"
@ -164,6 +169,7 @@ export const CapabilityConfig = ({
render={({ onChange, value }) => (
<InputGroup>
<Checkbox
data-testid="service-account"
label={t("serviceAccount")}
id="kc-flow-service-account"
name="serviceAccountsEnabled"
@ -207,6 +213,7 @@ export const CapabilityConfig = ({
defaultValue="false"
render={({ onChange, value }) => (
<Switch
data-testid="encrypt"
id="kc-encrypt"
label={t("common:on")}
labelOff={t("common:off")}
@ -233,6 +240,7 @@ export const CapabilityConfig = ({
defaultValue="false"
render={({ onChange, value }) => (
<Switch
data-testid="client-signature"
id="kc-client-signature"
label={t("common:on")}
labelOff={t("common:off")}

View file

@ -105,6 +105,14 @@
"directAccess": "Direct access",
"serviceAccount": "Service account roles",
"enableServiceAccount": "Enable service account roles",
"assignRolesTo": "Assign roles to {{client}} account",
"searchByRoleName": "Search by role name",
"filterByOrigin": "Filter by Origin",
"realmRoles": "Realm roles",
"clients": "Clients",
"assign": "Assign",
"roleMappingUpdatedSuccess": "Role mapping updated",
"roleMappingUpdatedError": "Could not update role mapping {{error}}",
"displayOnClient": "Display client on screen",
"consentScreenText": "Client consent screen text",
"loginSettings": "Login settings",

View file

@ -9,6 +9,7 @@ import {
FormGroup,
Grid,
GridItem,
PageSection,
Select,
SelectOption,
SelectVariant,
@ -253,92 +254,95 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => {
return (
<>
<TextContent className="keycloak__scopes_evaluate__intro">
<Text>
<QuestionCircleIcon /> {t("clients-help:evaluateExplain")}
</Text>
</TextContent>
<Form isHorizontal>
<FormGroup
label={t("scopeParameter")}
fieldId="scopeParameter"
labelIcon={
<HelpItem
helpText="clients-help:scopeParameter"
forLabel={t("scopeParameter")}
forID="scopeParameter"
/>
}
>
<Split hasGutter>
<SplitItem isFilled>
<Select
toggleId="scopeParameter"
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel={t("scopeParameter")}
onToggle={() => setIsScopeOpen(!isScopeOpen)}
isOpen={isScopeOpen}
selections={selected}
onSelect={(_, value) => {
const option = value as string;
if (selected.includes(option)) {
if (option !== prefix) {
setSelected(selected.filter((item) => item !== option));
<PageSection variant="light">
<TextContent className="keycloak__scopes_evaluate__intro">
<Text>
<QuestionCircleIcon /> {t("clients-help:evaluateExplain")}
</Text>
</TextContent>
<Form isHorizontal>
<FormGroup
label={t("scopeParameter")}
fieldId="scopeParameter"
labelIcon={
<HelpItem
helpText="clients-help:scopeParameter"
forLabel={t("scopeParameter")}
forID="scopeParameter"
/>
}
>
<Split hasGutter>
<SplitItem isFilled>
<Select
toggleId="scopeParameter"
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel={t("scopeParameter")}
onToggle={() => setIsScopeOpen(!isScopeOpen)}
isOpen={isScopeOpen}
selections={selected}
onSelect={(_, value) => {
const option = value as string;
if (selected.includes(option)) {
if (option !== prefix) {
setSelected(selected.filter((item) => item !== option));
}
} else {
setSelected([...selected, option]);
}
} else {
setSelected([...selected, option]);
}
}}
aria-labelledby={t("scopeParameter")}
placeholderText={t("scopeParameterPlaceholder")}
>
{selectableScopes.map((option, index) => (
<SelectOption key={index} value={option.name} />
))}
</Select>
</SplitItem>
<SplitItem>
<ClipboardCopy className="keycloak__scopes_evaluate__clipboard-copy">
{selected.join(" ")}
</ClipboardCopy>
</SplitItem>
</Split>
</FormGroup>
<FormGroup
label={t("user")}
fieldId="user"
labelIcon={
<HelpItem
helpText="clients-help:user"
forLabel={t("user")}
forID="user"
}}
aria-labelledby={t("scopeParameter")}
placeholderText={t("scopeParameterPlaceholder")}
>
{selectableScopes.map((option, index) => (
<SelectOption key={index} value={option.name} />
))}
</Select>
</SplitItem>
<SplitItem>
<ClipboardCopy className="keycloak__scopes_evaluate__clipboard-copy">
{selected.join(" ")}
</ClipboardCopy>
</SplitItem>
</Split>
</FormGroup>
<FormGroup
label={t("user")}
fieldId="user"
labelIcon={
<HelpItem
helpText="clients-help:user"
forLabel={t("user")}
forID="user"
/>
}
>
<Select
toggleId="user"
variant={SelectVariant.typeahead}
typeAheadAriaLabel={t("user")}
onToggle={() => setIsUserOpen(!isUserOpen)}
onFilter={(e) => {
const value = e?.target.value || "";
setUserSearch(value);
return userItems;
}}
onClear={() => {
setUser(undefined);
setUserSearch("");
}}
selections={[user]}
onSelect={(_, value) => {
setUser(value as UserRepresentation);
setUserSearch("");
setIsUserOpen(false);
}}
isOpen={isUserOpen}
/>
}
>
<Select
toggleId="user"
variant={SelectVariant.typeahead}
typeAheadAriaLabel={t("user")}
onToggle={() => setIsUserOpen(!isUserOpen)}
onFilter={(e) => {
const value = e?.target.value || "";
setUserSearch(value);
return userItems;
}}
onClear={() => {
setUser(undefined);
setUserSearch("");
}}
selections={[user]}
onSelect={(_, value) => {
setUser(value as UserRepresentation);
setUserSearch("");
setIsUserOpen(false);
}}
isOpen={isUserOpen}
/>
</FormGroup>
</Form>
</FormGroup>
</Form>
</PageSection>
<Grid hasGutter className="keycloak__scopes_evaluate__tabs">
<GridItem span={8}>
<TabContent

View file

@ -0,0 +1,269 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "react-error-boundary";
import _ from "lodash";
import {
Badge,
Button,
Chip,
ChipGroup,
Divider,
Modal,
ModalVariant,
Select,
SelectGroup,
SelectOption,
SelectVariant,
ToolbarItem,
} from "@patternfly/react-core";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import {
asyncStateFetch,
useAdminClient,
} from "../../context/auth/AdminClient";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { FilterIcon } from "@patternfly/react-icons";
import { Row, ServiceRole } from "./ServiceAccount";
type AddServiceAccountModalProps = {
clientId: string;
serviceAccountId: string;
onAssign: (rows: Row[]) => void;
onClose: () => void;
};
type ClientRole = ClientRepresentation & {
numberOfRoles: number;
};
const realmRole = {
name: "realmRoles",
} as ClientRepresentation;
export const AddServiceAccountModal = ({
clientId,
serviceAccountId,
onAssign,
onClose,
}: AddServiceAccountModalProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const errorHandler = useErrorHandler();
const [clients, setClients] = useState<ClientRole[]>([]);
const [searchToggle, setSearchToggle] = useState(false);
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const [selectedClients, setSelectedClients] = useState<ClientRole[]>([]);
const [selectedRows, setSelectedRows] = useState<Row[]>();
useEffect(
() =>
asyncStateFetch(
async () => {
const clients = await adminClient.clients.find();
return (
await Promise.all(
clients.map(async (client) => {
const roles = await adminClient.users.listAvailableClientRoleMappings(
{
id: serviceAccountId,
clientUniqueId: client.id!,
}
);
return {
roles,
client,
};
})
)
)
.flat()
.filter((row) => row.roles.length !== 0)
.map((row) => {
return { ...row.client, numberOfRoles: row.roles.length };
});
},
(clients) => {
setClients(clients);
},
errorHandler
),
[]
);
useEffect(refresh, [searchToggle]);
const removeClient = (client: ClientRole) => {
setSelectedClients(selectedClients.filter((item) => item.id !== client.id));
};
const loader = async () => {
const realmRolesSelected = _.findIndex(
selectedClients,
(client) => client.name === "realmRoles"
);
let selected = selectedClients;
if (realmRolesSelected !== -1) {
selected = selectedClients.filter(
(client) => client.name !== "realmRoles"
);
}
const realmRoles = (
await adminClient.users.listAvailableRealmRoleMappings({
id: serviceAccountId,
})
).map((role) => {
return {
role,
client: undefined,
};
});
const allClients =
selectedClients.length !== 0
? selected
: await adminClient.clients.find();
const roles = (
await Promise.all(
allClients.map(async (client) =>
(
await adminClient.users.listAvailableClientRoleMappings({
id: serviceAccountId,
clientUniqueId: client.id!,
})
).map((role) => {
return {
role,
client,
};
})
)
)
).flat();
return [
...(realmRolesSelected !== -1 || selected.length === 0 ? realmRoles : []),
...roles,
];
};
const createSelectGroup = (clients: ClientRepresentation[]) => [
<SelectGroup key="role" label={t("realmRoles")}>
<SelectOption key="realmRoles" value={realmRole}>
{t("realmRoles")}
</SelectOption>
</SelectGroup>,
<Divider key="divider" />,
<SelectGroup key="group" label={t("clients")}>
{clients.map((client) => (
<SelectOption key={client.id} value={client}>
{client.clientId}
</SelectOption>
))}
</SelectGroup>,
];
return (
<Modal
variant={ModalVariant.large}
title={t("assignRolesTo", { client: clientId })}
isOpen={true}
onClose={onClose}
actions={[
<Button
data-testid="assign"
key="confirm"
isDisabled={selectedRows?.length === 0}
variant="primary"
onClick={() => {
onAssign(selectedRows!);
onClose();
}}
>
{t("assign")}
</Button>,
<Button
data-testid="cancel"
key="cancel"
variant="secondary"
onClick={onClose}
>
{t("common:cancel")}
</Button>,
]}
>
<Select
toggleId="role"
onToggle={() => setSearchToggle(!searchToggle)}
isOpen={searchToggle}
variant={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
key={key}
onSelect={(rows) => setSelectedRows([...rows])}
searchPlaceholderKey="clients:searchByRoleName"
canSelectAll={false}
loader={loader}
ariaLabelKey="clients:roles"
columns={[
{
name: "name",
cellRenderer: ServiceRole,
},
{
name: "role.description",
displayKey: t("description"),
},
]}
/>
</Modal>
);
};

View file

@ -1,25 +1,50 @@
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge, Button, Checkbox, ToolbarItem } from "@patternfly/react-core";
import {
AlertVariant,
Badge,
Button,
Checkbox,
ToolbarItem,
} from "@patternfly/react-core";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import RoleRepresentation, {
RoleMappingPayload,
} from "keycloak-admin/lib/defs/roleRepresentation";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { useAdminClient } from "../../context/auth/AdminClient";
import { RealmContext } from "../../context/realm-context/RealmContext";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { emptyFormatter } from "../../util";
import { AddServiceAccountModal } from "./AddServiceAccountModal";
import "./service-account.css";
import { useAlerts } from "../../components/alert/Alerts";
type ServiceAccountProps = {
clientId: string;
};
type Row = {
client: ClientRepresentation;
role: CompositeRole;
export type Row = {
client?: ClientRepresentation;
role: CompositeRole | RoleRepresentation;
};
export const ServiceRole = ({ role, client }: Row) => (
<>
{client && (
<Badge
key={`${client.id}-${role.id}`}
isRead
className="keycloak-admin--service-account__client-name"
>
{client.clientId}
</Badge>
)}
{role.name}
</>
);
type CompositeRole = RoleRepresentation & {
parent: RoleRepresentation;
};
@ -28,13 +53,20 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const { realm } = useContext(RealmContext);
const { addAlert } = useAlerts();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const [hide, setHide] = useState(false);
const [serviceAccountId, setServiceAccountId] = useState("");
const [showAssign, setShowAssign] = useState(false);
const loader = async () => {
const serviceAccount = await adminClient.clients.getServiceAccountUser({
id: clientId,
});
setServiceAccountId(serviceAccount.id!);
const effectiveRoles = await adminClient.users.listCompositeRealmRoleMappings(
{ id: serviceAccount.id! }
);
@ -65,7 +97,6 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
};
const clientRolesFlat = clientRoles.map((row) => row.roles).flat();
console.log(clientRolesFlat);
const addInherentData = await (async () =>
Promise.all(
@ -99,59 +130,90 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
});
};
const RoleLink = ({ role, client }: Row) => (
const assignRoles = async (rows: Row[]) => {
try {
const realmRoles = rows
.filter((row) => row.client === undefined)
.map((row) => row.role as RoleMappingPayload)
.flat();
adminClient.users.addRealmRoleMappings({
id: serviceAccountId,
roles: realmRoles,
});
await Promise.all(
rows
.filter((row) => row.client !== undefined)
.map((row) =>
adminClient.users.addClientRoleMappings({
id: serviceAccountId,
clientUniqueId: row.client!.id!,
roles: [row.role as RoleMappingPayload],
})
)
);
addAlert(t("roleMappingUpdatedSuccess"), AlertVariant.success);
refresh();
} catch (error) {
addAlert(
t("roleMappingUpdatedError", {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
return (
<>
{client && (
<Badge
key={client.id}
isRead
className="keycloak-admin--service-account__client-name"
>
{client.clientId}
</Badge>
{showAssign && (
<AddServiceAccountModal
clientId={clientId}
serviceAccountId={serviceAccountId}
onAssign={assignRoles}
onClose={() => setShowAssign(false)}
/>
)}
{role.name}
<KeycloakDataTable
data-testid="assigned-roles"
key={key}
loader={loader}
onSelect={() => {}}
searchPlaceholderKey="clients:searchByName"
ariaLabelKey="clients:clientScopeList"
toolbarItem={
<>
<ToolbarItem>
<Checkbox
label={t("hideInheritedRoles")}
id="hideInheritedRoles"
isChecked={hide}
onChange={setHide}
/>
</ToolbarItem>
<ToolbarItem>
<Button onClick={() => setShowAssign(true)}>
{t("assignRole")}
</Button>
</ToolbarItem>
</>
}
columns={[
{
name: "role.name",
displayKey: t("name"),
cellRenderer: ServiceRole,
},
{
name: "role.parent.name",
displayKey: t("inherentFrom"),
cellFormatters: [emptyFormatter()],
},
{
name: "role.description",
displayKey: t("description"),
cellFormatters: [emptyFormatter()],
},
]}
/>
</>
);
return (
<KeycloakDataTable
loader={loader}
onSelect={() => {}}
searchPlaceholderKey="clients:searchByName"
ariaLabelKey="clients:clientScopeList"
toolbarItem={
<>
<ToolbarItem>
<Checkbox
label={t("hideInheritedRoles")}
id="hideInheritedRoles"
isChecked={hide}
onChange={setHide}
/>
</ToolbarItem>
<ToolbarItem>
<Button>{t("assignRole")}</Button>
</ToolbarItem>
</>
}
columns={[
{
name: "role.name",
displayKey: t("name"),
cellRenderer: RoleLink,
},
{
name: "role.parent.name",
displayKey: t("inherentFrom"),
cellFormatters: [emptyFormatter()],
},
{
name: "role.description",
displayKey: t("description"),
cellFormatters: [emptyFormatter()],
},
]}
/>
);
};

View file

@ -43,10 +43,12 @@ function DataTable<T>({
ariaLabelKey,
onSelect,
canSelectAll,
...props
}: DataTableProps<T>) {
const { t } = useTranslation();
return (
<Table
{...props}
variant={TableVariant.compact}
onSelect={
onSelect
@ -130,6 +132,7 @@ export function KeycloakDataTable<T>({
searchTypeComponent,
toolbarItem,
emptyState,
...props
}: DataListProps<T>) {
const { t } = useTranslation();
const [selected, setSelected] = useState<T[]>([]);
@ -281,6 +284,7 @@ export function KeycloakDataTable<T>({
>
{!loading && (filteredData || rows).length > 0 && (
<DataTable
{...props}
canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined}
actions={convertAction()}
@ -290,14 +294,17 @@ export function KeycloakDataTable<T>({
ariaLabelKey={ariaLabelKey}
/>
)}
{!loading && rows.length === 0 && search !== "" && (
<ListEmptyState
hasIcon={true}
isSearchVariant={true}
message={t("noSearchResults")}
instructions={t("noSearchResultsInstructions")}
/>
)}
{!loading &&
rows.length === 0 &&
search !== "" &&
searchPlaceholderKey && (
<ListEmptyState
hasIcon={true}
isSearchVariant={true}
message={t("noSearchResults")}
instructions={t("noSearchResultsInstructions")}
/>
)}
{loading && <Loading />}
</PaginatingTableToolbar>
)}