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 AdminClient from "../support/util/AdminClient";
import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients/InitialAccessTokenTab"; import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients/InitialAccessTokenTab";
import { keycloakBefore } from "../support/util/keycloak_before"; import { keycloakBefore } from "../support/util/keycloak_before";
import ServiceAccountTab from "../support/pages/admin_console/manage/clients/ServiceAccountTab";
let itemId = "client_crud"; let itemId = "client_crud";
const loginPage = new LoginPage(); const loginPage = new LoginPage();
@ -162,4 +163,39 @@ describe("Clients test", function () {
advancedTab.checkAccessTokenSignatureAlgorithm(algorithm); 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; return this;
} }
changeSwitches(switches: string[]) {
for (const uiSwitch of switches) {
cy.getId(uiSwitch).check({ force: true });
}
return this;
}
checkClientTypeRequiredMessage(exist = true) { checkClientTypeRequiredMessage(exist = true) {
cy.get(this.clientTypeError).should((!exist ? "not." : "") + "exist"); 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 KeycloakAdminClient from "keycloak-admin";
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation"; import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
export default class AdminClient { export default class AdminClient {
private client: KeycloakAdminClient; private client: KeycloakAdminClient;
@ -24,6 +25,10 @@ export default class AdminClient {
await this.client.realms.del({ realm }); await this.client.realms.del({ realm });
} }
async createClient(client: ClientRepresentation) {
await this.login();
await this.client.clients.create(client);
}
async deleteClient(clientName: string) { async deleteClient(clientName: string) {
await this.login(); await this.login();
const client = ( const client = (

View file

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

View file

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

View file

@ -105,6 +105,14 @@
"directAccess": "Direct access", "directAccess": "Direct access",
"serviceAccount": "Service account roles", "serviceAccount": "Service account roles",
"enableServiceAccount": "Enable 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", "displayOnClient": "Display client on screen",
"consentScreenText": "Client consent screen text", "consentScreenText": "Client consent screen text",
"loginSettings": "Login settings", "loginSettings": "Login settings",

View file

@ -9,6 +9,7 @@ import {
FormGroup, FormGroup,
Grid, Grid,
GridItem, GridItem,
PageSection,
Select, Select,
SelectOption, SelectOption,
SelectVariant, SelectVariant,
@ -253,92 +254,95 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => {
return ( return (
<> <>
<TextContent className="keycloak__scopes_evaluate__intro"> <PageSection variant="light">
<Text> <TextContent className="keycloak__scopes_evaluate__intro">
<QuestionCircleIcon /> {t("clients-help:evaluateExplain")} <Text>
</Text> <QuestionCircleIcon /> {t("clients-help:evaluateExplain")}
</TextContent> </Text>
<Form isHorizontal> </TextContent>
<FormGroup <Form isHorizontal>
label={t("scopeParameter")} <FormGroup
fieldId="scopeParameter" label={t("scopeParameter")}
labelIcon={ fieldId="scopeParameter"
<HelpItem labelIcon={
helpText="clients-help:scopeParameter" <HelpItem
forLabel={t("scopeParameter")} helpText="clients-help:scopeParameter"
forID="scopeParameter" forLabel={t("scopeParameter")}
/> forID="scopeParameter"
} />
> }
<Split hasGutter> >
<SplitItem isFilled> <Split hasGutter>
<Select <SplitItem isFilled>
toggleId="scopeParameter" <Select
variant={SelectVariant.typeaheadMulti} toggleId="scopeParameter"
typeAheadAriaLabel={t("scopeParameter")} variant={SelectVariant.typeaheadMulti}
onToggle={() => setIsScopeOpen(!isScopeOpen)} typeAheadAriaLabel={t("scopeParameter")}
isOpen={isScopeOpen} onToggle={() => setIsScopeOpen(!isScopeOpen)}
selections={selected} isOpen={isScopeOpen}
onSelect={(_, value) => { selections={selected}
const option = value as string; onSelect={(_, value) => {
if (selected.includes(option)) { const option = value as string;
if (option !== prefix) { if (selected.includes(option)) {
setSelected(selected.filter((item) => item !== 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")}
}} >
aria-labelledby={t("scopeParameter")} {selectableScopes.map((option, index) => (
placeholderText={t("scopeParameterPlaceholder")} <SelectOption key={index} value={option.name} />
> ))}
{selectableScopes.map((option, index) => ( </Select>
<SelectOption key={index} value={option.name} /> </SplitItem>
))} <SplitItem>
</Select> <ClipboardCopy className="keycloak__scopes_evaluate__clipboard-copy">
</SplitItem> {selected.join(" ")}
<SplitItem> </ClipboardCopy>
<ClipboardCopy className="keycloak__scopes_evaluate__clipboard-copy"> </SplitItem>
{selected.join(" ")} </Split>
</ClipboardCopy> </FormGroup>
</SplitItem> <FormGroup
</Split> label={t("user")}
</FormGroup> fieldId="user"
<FormGroup labelIcon={
label={t("user")} <HelpItem
fieldId="user" helpText="clients-help:user"
labelIcon={ forLabel={t("user")}
<HelpItem forID="user"
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}
/> />
} </FormGroup>
> </Form>
<Select </PageSection>
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>
<Grid hasGutter className="keycloak__scopes_evaluate__tabs"> <Grid hasGutter className="keycloak__scopes_evaluate__tabs">
<GridItem span={8}> <GridItem span={8}>
<TabContent <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 React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; 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 ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { useAdminClient } from "../../context/auth/AdminClient"; import { useAdminClient } from "../../context/auth/AdminClient";
import { RealmContext } from "../../context/realm-context/RealmContext"; import { RealmContext } from "../../context/realm-context/RealmContext";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { emptyFormatter } from "../../util"; import { emptyFormatter } from "../../util";
import { AddServiceAccountModal } from "./AddServiceAccountModal";
import "./service-account.css"; import "./service-account.css";
import { useAlerts } from "../../components/alert/Alerts";
type ServiceAccountProps = { type ServiceAccountProps = {
clientId: string; clientId: string;
}; };
type Row = { export type Row = {
client: ClientRepresentation; client?: ClientRepresentation;
role: CompositeRole; 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 & { type CompositeRole = RoleRepresentation & {
parent: RoleRepresentation; parent: RoleRepresentation;
}; };
@ -28,13 +53,20 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const { realm } = useContext(RealmContext); const { realm } = useContext(RealmContext);
const { addAlert } = useAlerts();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const [hide, setHide] = useState(false); const [hide, setHide] = useState(false);
const [serviceAccountId, setServiceAccountId] = useState("");
const [showAssign, setShowAssign] = useState(false);
const loader = async () => { const loader = async () => {
const serviceAccount = await adminClient.clients.getServiceAccountUser({ const serviceAccount = await adminClient.clients.getServiceAccountUser({
id: clientId, id: clientId,
}); });
setServiceAccountId(serviceAccount.id!);
const effectiveRoles = await adminClient.users.listCompositeRealmRoleMappings( const effectiveRoles = await adminClient.users.listCompositeRealmRoleMappings(
{ id: serviceAccount.id! } { id: serviceAccount.id! }
); );
@ -65,7 +97,6 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
}; };
const clientRolesFlat = clientRoles.map((row) => row.roles).flat(); const clientRolesFlat = clientRoles.map((row) => row.roles).flat();
console.log(clientRolesFlat);
const addInherentData = await (async () => const addInherentData = await (async () =>
Promise.all( 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 && ( {showAssign && (
<Badge <AddServiceAccountModal
key={client.id} clientId={clientId}
isRead serviceAccountId={serviceAccountId}
className="keycloak-admin--service-account__client-name" onAssign={assignRoles}
> onClose={() => setShowAssign(false)}
{client.clientId} />
</Badge>
)} )}
{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, ariaLabelKey,
onSelect, onSelect,
canSelectAll, canSelectAll,
...props
}: DataTableProps<T>) { }: DataTableProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Table <Table
{...props}
variant={TableVariant.compact} variant={TableVariant.compact}
onSelect={ onSelect={
onSelect onSelect
@ -130,6 +132,7 @@ export function KeycloakDataTable<T>({
searchTypeComponent, searchTypeComponent,
toolbarItem, toolbarItem,
emptyState, emptyState,
...props
}: DataListProps<T>) { }: DataListProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
const [selected, setSelected] = useState<T[]>([]); const [selected, setSelected] = useState<T[]>([]);
@ -281,6 +284,7 @@ export function KeycloakDataTable<T>({
> >
{!loading && (filteredData || rows).length > 0 && ( {!loading && (filteredData || rows).length > 0 && (
<DataTable <DataTable
{...props}
canSelectAll={canSelectAll} canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined} onSelect={onSelect ? _onSelect : undefined}
actions={convertAction()} actions={convertAction()}
@ -290,14 +294,17 @@ export function KeycloakDataTable<T>({
ariaLabelKey={ariaLabelKey} ariaLabelKey={ariaLabelKey}
/> />
)} )}
{!loading && rows.length === 0 && search !== "" && ( {!loading &&
<ListEmptyState rows.length === 0 &&
hasIcon={true} search !== "" &&
isSearchVariant={true} searchPlaceholderKey && (
message={t("noSearchResults")} <ListEmptyState
instructions={t("noSearchResultsInstructions")} hasIcon={true}
/> isSearchVariant={true}
)} message={t("noSearchResults")}
instructions={t("noSearchResultsInstructions")}
/>
)}
{loading && <Loading />} {loading && <Loading />}
</PaginatingTableToolbar> </PaginatingTableToolbar>
)} )}