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,12 +177,15 @@ export const AdvancedTab = ({
} }
return ( return (
<PageSection variant="light">
<ScrollForm sections={sections}> <ScrollForm sections={sections}>
<> <>
<Text className="pf-u-py-lg"> <Text className="pf-u-py-lg">
<Trans i18nKey="clients-help:notBeforeIntro"> <Trans i18nKey="clients-help:notBeforeIntro">
In order to successfully push setup url on In order to successfully push setup url on
<Link to={`/${realm}/clients/${id}/settings`}>{t("settings")}</Link> <Link to={`/${realm}/clients/${id}/settings`}>
{t("settings")}
</Link>
tab tab
</Trans> </Trans>
</Text> </Text>
@ -428,5 +432,6 @@ export const AdvancedTab = ({
/> />
</> </>
</ScrollForm> </ScrollForm>
</PageSection>
); );
}; };

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,6 +254,7 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => {
return ( return (
<> <>
<PageSection variant="light">
<TextContent className="keycloak__scopes_evaluate__intro"> <TextContent className="keycloak__scopes_evaluate__intro">
<Text> <Text>
<QuestionCircleIcon /> {t("clients-help:evaluateExplain")} <QuestionCircleIcon /> {t("clients-help:evaluateExplain")}
@ -339,6 +341,8 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => {
/> />
</FormGroup> </FormGroup>
</Form> </Form>
</PageSection>
<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,23 +130,51 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
}); });
}; };
const RoleLink = ({ role, client }: Row) => ( const assignRoles = async (rows: Row[]) => {
<> try {
{client && ( const realmRoles = rows
<Badge .filter((row) => row.client === undefined)
key={client.id} .map((row) => row.role as RoleMappingPayload)
isRead .flat();
className="keycloak-admin--service-account__client-name" adminClient.users.addRealmRoleMappings({
> id: serviceAccountId,
{client.clientId} roles: realmRoles,
</Badge> });
)} await Promise.all(
{role.name} 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 ( return (
<>
{showAssign && (
<AddServiceAccountModal
clientId={clientId}
serviceAccountId={serviceAccountId}
onAssign={assignRoles}
onClose={() => setShowAssign(false)}
/>
)}
<KeycloakDataTable <KeycloakDataTable
data-testid="assigned-roles"
key={key}
loader={loader} loader={loader}
onSelect={() => {}} onSelect={() => {}}
searchPlaceholderKey="clients:searchByName" searchPlaceholderKey="clients:searchByName"
@ -131,7 +190,9 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
/> />
</ToolbarItem> </ToolbarItem>
<ToolbarItem> <ToolbarItem>
<Button>{t("assignRole")}</Button> <Button onClick={() => setShowAssign(true)}>
{t("assignRole")}
</Button>
</ToolbarItem> </ToolbarItem>
</> </>
} }
@ -139,7 +200,7 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
{ {
name: "role.name", name: "role.name",
displayKey: t("name"), displayKey: t("name"),
cellRenderer: RoleLink, cellRenderer: ServiceRole,
}, },
{ {
name: "role.parent.name", name: "role.parent.name",
@ -153,5 +214,6 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
}, },
]} ]}
/> />
</>
); );
}; };

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,7 +294,10 @@ export function KeycloakDataTable<T>({
ariaLabelKey={ariaLabelKey} ariaLabelKey={ariaLabelKey}
/> />
)} )}
{!loading && rows.length === 0 && search !== "" && ( {!loading &&
rows.length === 0 &&
search !== "" &&
searchPlaceholderKey && (
<ListEmptyState <ListEmptyState
hasIcon={true} hasIcon={true}
isSearchVariant={true} isSearchVariant={true}