Add scope tab to client scope detail page (#514)

* initial version of the scope tab

* fixed assign

* moved form logic added test

* added unassign

* fixed merge error

* fixed labels
This commit is contained in:
Erik Jan de Wit 2021-04-20 14:10:00 +02:00 committed by GitHub
parent 3332bd1a01
commit b86db32ba8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1071 additions and 435 deletions

View file

@ -4,6 +4,7 @@ import ListingPage from "../support/pages/admin_console/ListingPage";
import SidebarPage from "../support/pages/admin_console/SidebarPage";
import CreateClientScopePage from "../support/pages/admin_console/manage/client_scopes/CreateClientScopePage";
import { keycloakBefore } from "../support/util/keycloak_before";
import RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab";
let itemId = "client_scope_crud";
const loginPage = new LoginPage();
@ -57,4 +58,23 @@ describe("Client Scopes test", function () {
.itemExist(itemId, false);
});
});
describe("Scope test", () => {
const scopeTab = new RoleMappingTab();
const scopeName = "address";
beforeEach(() => {
keycloakBefore();
loginPage.logIn();
sidebarPage.goToClientScopes();
});
it("assignRole", () => {
const role = "offline_access";
listingPage.searchItem(scopeName, false).goToItemDetails(scopeName);
scopeTab.goToScopeTab().clickAssignRole().selectRow(role).clickAssign();
masthead.checkNotificationMessage("Role mapping updated");
scopeTab.checkRoles([role]);
});
});
});

View file

@ -8,7 +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";
import RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab";
let itemId = "client_crud";
const loginPage = new LoginPage();
@ -165,7 +165,7 @@ describe("Clients test", function () {
});
describe("Service account tab test", () => {
const serviceAccountTab = new ServiceAccountTab();
const serviceAccountTab = new RoleMappingTab();
const serviceAccountName = "service-account-client";
beforeEach(() => {
@ -194,7 +194,7 @@ describe("Clients test", function () {
.searchItem(serviceAccountName)
.goToItemDetails(serviceAccountName);
serviceAccountTab
.goToTab()
.goToServiceAccountTab()
.checkRoles(["manage-account", "offline_access", "uma_authorization"]);
});
});

View file

@ -0,0 +1,51 @@
const expect = chai.expect;
export default class RoleMappingTab {
private tab = "#pf-tab-serviceAccount-serviceAccount";
private scopeTab = "scopeTab";
private assignRole = "assignRole";
private assign = "assign";
private assignedRolesTable = "assigned-roles";
private namesColumn = 'td[data-label="Name"]:visible';
goToServiceAccountTab() {
cy.get(this.tab).click();
return this;
}
goToScopeTab() {
cy.getId(this.scopeTab).click();
return this;
}
clickAssignRole() {
cy.getId(this.assignRole).click();
return this;
}
clickAssign() {
cy.getId(this.assign).click();
return this;
}
selectRow(name: string) {
cy.get(this.namesColumn)
.contains(name)
.parent()
.within(() => {
cy.get("input").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,23 +0,0 @@
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

@ -24,7 +24,7 @@
"@patternfly/react-table": "4.24.1",
"file-saver": "^2.0.2",
"i18next": "^19.6.2",
"keycloak-admin": "1.14.10",
"keycloak-admin": "1.14.11",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"react": "^16.8.5",

View file

@ -0,0 +1,255 @@
import React, { useEffect, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form";
import {
Form,
FormGroup,
ValidatedOptions,
TextInput,
Select,
SelectVariant,
SelectOption,
Switch,
ActionGroup,
Button,
} from "@patternfly/react-core";
import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { useLoginProviders } from "../../context/server-info/ServerInfoProvider";
import { convertToFormValues } from "../../util";
type ScopeFormProps = {
clientScope: ClientScopeRepresentation;
save: (clientScope: ClientScopeRepresentation) => void;
};
export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
const { t } = useTranslation("client-scopes");
const { register, control, handleSubmit, errors, setValue } = useForm();
const history = useHistory();
const providers = useLoginProviders();
const [open, isOpen] = useState(false);
const { id } = useParams<{ id: string }>();
useEffect(() => {
Object.entries(clientScope).map((entry) => {
if (entry[0] === "attributes") {
convertToFormValues(entry[1], "attributes", setValue);
}
setValue(entry[0], entry[1]);
});
}, [clientScope]);
return (
<Form isHorizontal onSubmit={handleSubmit(save)} className="pf-u-mt-md">
<FormGroup
label={t("common:name")}
labelIcon={
<HelpItem
helpText="client-scopes-help:name"
forLabel={t("common:name")}
forID="kc-name"
/>
}
fieldId="kc-name"
isRequired
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register({ required: true })}
type="text"
id="kc-name"
name="name"
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup
label={t("common:description")}
labelIcon={
<HelpItem
helpText="client-scopes-help:description"
forLabel={t("common:description")}
forID="kc-description"
/>
}
fieldId="kc-description"
validated={
errors.description ? ValidatedOptions.error : ValidatedOptions.default
}
helperTextInvalid={t("common:maxLength", { length: 255 })}
>
<TextInput
ref={register({
maxLength: 255,
})}
validated={
errors.description
? ValidatedOptions.error
: ValidatedOptions.default
}
type="text"
id="kc-description"
name="description"
/>
</FormGroup>
{!id && (
<FormGroup
label={t("protocol")}
labelIcon={
<HelpItem
helpText="client-scopes-help:protocol"
forLabel="protocol"
forID="kc-protocol"
/>
}
fieldId="kc-protocol"
>
<Controller
name="protocol"
defaultValue={providers[0]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-protocol"
required
onToggle={() => isOpen(!open)}
onSelect={(_, value) => {
onChange(value as string);
isOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("selectEncryptionType")}
isOpen={open}
>
{providers.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
/>
))}
</Select>
)}
/>
</FormGroup>
)}
<FormGroup
hasNoPaddingTop
label={t("displayOnConsentScreen")}
labelIcon={
<HelpItem
helpText="client-scopes-help:displayOnConsentScreen"
forLabel={t("displayOnConsentScreen")}
forID="kc-display.on.consent.screen"
/>
}
fieldId="kc-display.on.consent.screen"
>
<Controller
name="attributes.display-on-consent-screen"
control={control}
defaultValue="false"
render={({ onChange, value }) => (
<Switch
id="kc-display.on.consent.screen"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("consentScreenText")}
labelIcon={
<HelpItem
helpText="client-scopes-help:consentScreenText"
forLabel={t("consentScreenText")}
forID="kc-consent-screen-text"
/>
}
fieldId="kc-consent-screen-text"
>
<TextInput
ref={register}
type="text"
id="kc-consent-screen-text"
name="attributes.consent-screen-text"
/>
</FormGroup>
<FormGroup
hasNoPaddingTop
label={t("includeInTokenScope")}
labelIcon={
<HelpItem
helpText="client-scopes-help:includeInTokenScope"
forLabel={t("includeInTokenScope")}
forID="includeInTokenScope"
/>
}
fieldId="includeInTokenScope"
>
<Controller
name="attributes.include-in-token-scope"
control={control}
defaultValue="false"
render={({ onChange, value }) => (
<Switch
id="includeInTokenScope"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("guiOrder")}
labelIcon={
<HelpItem
helpText="client-scopes-help:guiOrder"
forLabel={t("guiOrder")}
forID="kc-gui-order"
/>
}
fieldId="kc-gui-order"
helperTextInvalid={t("shouldBeANumber")}
validated={
errors.attributes && errors.attributes["gui_order"]
? ValidatedOptions.error
: ValidatedOptions.default
}
>
<TextInput
ref={register({ pattern: /^([0-9]*)$/ })}
type="text"
id="kc-gui-order"
name="attributes.gui-order"
validated={
errors.attributes && errors.attributes["gui_order"]
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button variant="link" onClick={() => history.push("/client-scopes/")}>
{t("common:cancel")}
</Button>
</ActionGroup>
</Form>
);
};

View file

@ -1,52 +1,38 @@
import React, { useEffect, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useErrorHandler } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import {
ActionGroup,
AlertVariant,
Button,
Form,
FormGroup,
PageSection,
Select,
SelectOption,
SelectVariant,
Switch,
Spinner,
Tab,
TabTitleText,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form";
import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation";
import {
useAdminClient,
asyncStateFetch,
} from "../../context/auth/AdminClient";
import { KeycloakTabs } from "../../components/keycloak-tabs/KeycloakTabs";
import { useAlerts } from "../../components/alert/Alerts";
import { useLoginProviders } from "../../context/server-info/ServerInfoProvider";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { convertFormValuesToObject, convertToFormValues } from "../../util";
import { convertFormValuesToObject } from "../../util";
import { MapperList } from "../details/MapperList";
import { ScopeForm } from "../details/ScopeForm";
import { RoleMapping, Row } from "../../components/role-mapping/RoleMapping";
import { RoleMappingPayload } from "keycloak-admin/lib/defs/roleRepresentation";
export const ClientScopeForm = () => {
const { t } = useTranslation("client-scopes");
const { register, control, handleSubmit, errors, setValue } = useForm<
ClientScopeRepresentation
>();
const history = useHistory();
const [clientScope, setClientScope] = useState<ClientScopeRepresentation>();
const [hide, setHide] = useState(false);
const adminClient = useAdminClient();
const handleError = useErrorHandler();
const providers = useLoginProviders();
const { id } = useParams<{ id: string }>();
const [open, isOpen] = useState(false);
const { addAlert } = useAlerts();
const [key, setKey] = useState(0);
@ -56,23 +42,53 @@ export const ClientScopeForm = () => {
return asyncStateFetch(
async () => {
if (id) {
const data = await adminClient.clientScopes.findOne({ id });
if (data) {
Object.entries(data).map((entry) => {
if (entry[0] === "attributes") {
convertToFormValues(entry[1], "attributes", setValue);
}
setValue(entry[0], entry[1]);
});
}
return data;
return await adminClient.clientScopes.findOne({ id });
}
},
(data) => setClientScope(data),
(clientScope) => {
setClientScope(clientScope);
},
handleError
);
}, [key]);
}, [key, id]);
const loader = async () => {
const assignedRoles = hide
? await adminClient.clientScopes.listRealmScopeMappings({ id })
: await adminClient.clientScopes.listCompositeRealmScopeMappings({ id });
const clients = await adminClient.clients.find();
const clientRoles = (
await Promise.all(
clients.map(async (client) => {
const clientScope = hide
? await adminClient.clientScopes.listClientScopeMappings({
id,
client: client.id!,
})
: await adminClient.clientScopes.listCompositeClientScopeMappings({
id,
client: client.id!,
});
return clientScope.map((scope) => {
return {
client,
role: scope,
};
});
})
)
).flat();
return [
...assignedRoles.map((role) => {
return {
role,
};
}),
...clientRoles,
];
};
const save = async (clientScopes: ClientScopeRepresentation) => {
try {
@ -94,6 +110,50 @@ export const ClientScopeForm = () => {
}
};
const assignRoles = async (rows: Row[]) => {
try {
const realmRoles = rows
.filter((row) => row.client === undefined)
.map((row) => row.role as RoleMappingPayload)
.flat();
await adminClient.clientScopes.addRealmScopeMappings(
{
id,
},
realmRoles
);
await Promise.all(
rows
.filter((row) => row.client !== undefined)
.map((row) =>
adminClient.clientScopes.addClientScopeMappings(
{
id,
client: row.client!.id!,
},
[row.role as RoleMappingPayload]
)
)
);
addAlert(t("roleMappingUpdatedSuccess"), AlertVariant.success);
} catch (error) {
addAlert(
t("roleMappingUpdatedError", {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
if (id && !clientScope) {
return (
<div className="pf-u-text-align-center">
<Spinner />
</div>
);
}
return (
<>
<ViewHeader
@ -102,246 +162,47 @@ export const ClientScopeForm = () => {
}
subKey="client-scopes:clientScopeExplain"
badge={clientScope ? clientScope.protocol : undefined}
divider={!id}
/>
<PageSection variant="light">
<KeycloakTabs isBox>
<Tab
eventKey="settings"
title={<TabTitleText>{t("common:settings")}</TabTitleText>}
>
<Form
isHorizontal
onSubmit={handleSubmit(save)}
className="pf-u-mt-md"
<PageSection variant="light" className="pf-u-p-0">
{!id && (
<PageSection variant="light">
<ScopeForm save={save} clientScope={{}} />
</PageSection>
)}
{id && clientScope && (
<KeycloakTabs isBox>
<Tab
eventKey="settings"
title={<TabTitleText>{t("common:settings")}</TabTitleText>}
>
<PageSection variant="light">
<ScopeForm save={save} clientScope={clientScope} />
</PageSection>
</Tab>
<Tab
eventKey="mappers"
title={<TabTitleText>{t("common:mappers")}</TabTitleText>}
>
<FormGroup
label={t("common:name")}
labelIcon={
<HelpItem
helpText="client-scopes-help:name"
forLabel={t("common:name")}
forID="kc-name"
/>
}
fieldId="kc-name"
isRequired
validated={
errors.name
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register({ required: true })}
type="text"
id="kc-name"
name="name"
validated={
errors.name
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup
label={t("common:description")}
labelIcon={
<HelpItem
helpText="client-scopes-help:description"
forLabel={t("common:description")}
forID="kc-description"
/>
}
fieldId="kc-description"
validated={
errors.description
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:maxLength", { length: 255 })}
>
<TextInput
ref={register({
maxLength: 255,
})}
validated={
errors.description
? ValidatedOptions.error
: ValidatedOptions.default
}
type="text"
id="kc-description"
name="description"
/>
</FormGroup>
{!id && (
<FormGroup
label={t("protocol")}
labelIcon={
<HelpItem
helpText="client-scopes-help:protocol"
forLabel="protocol"
forID="kc-protocol"
/>
}
fieldId="kc-protocol"
>
<Controller
name="protocol"
defaultValue={providers[0]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-protocol"
required
onToggle={() => isOpen(!open)}
onSelect={(_, value) => {
onChange(value as string);
isOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("selectEncryptionType")}
isOpen={open}
>
{providers.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
/>
))}
</Select>
)}
/>
</FormGroup>
)}
<FormGroup
hasNoPaddingTop
label={t("displayOnConsentScreen")}
labelIcon={
<HelpItem
helpText="client-scopes-help:displayOnConsentScreen"
forLabel={t("displayOnConsentScreen")}
forID="kc-display.on.consent.screen"
/>
}
fieldId="kc-display.on.consent.screen"
>
<Controller
name="attributes.display-on-consent-screen"
control={control}
defaultValue="false"
render={({ onChange, value }) => (
<Switch
id="kc-display.on.consent.screen"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("consentScreenText")}
labelIcon={
<HelpItem
helpText="client-scopes-help:consentScreenText"
forLabel={t("consentScreenText")}
forID="kc-consent-screen-text"
/>
}
fieldId="kc-consent-screen-text"
>
<TextInput
ref={register}
type="text"
id="kc-consent-screen-text"
name="attributes.consent-screen-text"
/>
</FormGroup>
<FormGroup
hasNoPaddingTop
label={t("includeInTokenScope")}
labelIcon={
<HelpItem
helpText="client-scopes-help:includeInTokenScope"
forLabel={t("includeInTokenScope")}
forID="includeInTokenScope"
/>
}
fieldId="includeInTokenScope"
>
<Controller
name="attributes.include-in-token-scope"
control={control}
defaultValue="false"
render={({ onChange, value }) => (
<Switch
id="includeInTokenScope"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("guiOrder")}
labelIcon={
<HelpItem
helpText="client-scopes-help:guiOrder"
forLabel={t("guiOrder")}
forID="kc-gui-order"
/>
}
fieldId="kc-gui-order"
helperTextInvalid={t("shouldBeANumber")}
validated={
errors.attributes && errors.attributes["gui_order"]
? ValidatedOptions.error
: ValidatedOptions.default
}
>
<TextInput
ref={register({ pattern: /^([0-9]*)$/ })}
type="text"
id="kc-gui-order"
name="attributes.gui-order"
validated={
errors.attributes && errors.attributes["gui_order"]
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button
variant="link"
onClick={() => history.push("/client-scopes/")}
>
{t("common:cancel")}
</Button>
</ActionGroup>
</Form>
</Tab>
<Tab
isHidden={!id}
eventKey="mappers"
title={<TabTitleText>{t("common:mappers")}</TabTitleText>}
>
{clientScope && (
<MapperList clientScope={clientScope} refresh={refresh} />
)}
</Tab>
</KeycloakTabs>
</Tab>
<Tab
data-testid="scopeTab"
eventKey="scope"
title={<TabTitleText>{t("scope")}</TabTitleText>}
>
<RoleMapping
id={id}
name={clientScope.name!}
type={"client-scope"}
loader={loader}
save={assignRoles}
onHideRolesToggle={() => setHide(!hide)}
/>
</Tab>
</KeycloakTabs>
)}
</PageSection>
</>
);

View file

@ -54,6 +54,9 @@
"predefinedMappingDescription": "Choose one of the predefined mappings from this table",
"mappingTable": "Table with predefined mapping",
"roleGroup": "Use a realm role from:",
"clientGroup": "Use a client role from:"
"clientGroup": "Use a client role from:",
"scope": "Scope",
"roleMappingUpdatedSuccess": "Role mapping updated",
"roleMappingUpdatedError": "Could not update role mapping {{error}}"
}
}

View file

@ -30,6 +30,10 @@
"evaluate": "Evaluate",
"changeTypeTo": "Change type to",
"assignRole": "Assign role",
"unAssignRole": "Unassign",
"removeMappingTitle": "Remove mapping?",
"removeMappingConfirm": "Are you sure you want to remove this mapping?",
"removeMappingConfirm_plural": "Are you sure you want to remove {{count}} mappings",
"clientScopeSearch": {
"client": "Client scope",
"assigned": "Assigned type"

View file

@ -1,66 +1,32 @@
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
Badge,
Button,
Checkbox,
ToolbarItem,
} from "@patternfly/react-core";
import { AlertVariant } from "@patternfly/react-core";
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";
import {
CompositeRole,
RoleMapping,
Row,
} from "../../components/role-mapping/RoleMapping";
type ServiceAccountProps = {
clientId: string;
};
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;
};
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 [name, setName] = useState("");
const loader = async () => {
const serviceAccount = await adminClient.clients.getServiceAccountUser({
@ -75,6 +41,7 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
});
const clients = await adminClient.clients.find();
setName(clients.find((c) => c.id === clientId)?.clientId!);
const clientRoles = (
await Promise.all(
clients.map(async (client) => {
@ -152,7 +119,6 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
)
);
addAlert(t("roleMappingUpdatedSuccess"), AlertVariant.success);
refresh();
} catch (error) {
addAlert(
t("roleMappingUpdatedError", {
@ -163,57 +129,13 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
}
};
return (
<>
{showAssign && (
<AddServiceAccountModal
clientId={clientId}
serviceAccountId={serviceAccountId}
onAssign={assignRoles}
onClose={() => setShowAssign(false)}
/>
)}
<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()],
},
]}
/>
</>
<RoleMapping
name={name}
id={serviceAccountId}
type={"service-account"}
loader={loader}
save={assignRoles}
onHideRolesToggle={() => setHide(!hide)}
/>
);
};

View file

@ -17,18 +17,22 @@ import {
ToolbarItem,
} from "@patternfly/react-core";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { KeycloakDataTable } from "../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";
import { Row, ServiceRole } from "./RoleMapping";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
type AddServiceAccountModalProps = {
clientId: string;
serviceAccountId: string;
export type MappingType = "service-account" | "client-scope";
type AddRoleMappingModalProps = {
id: string;
type: MappingType;
name: string;
onAssign: (rows: Row[]) => void;
onClose: () => void;
};
@ -41,18 +45,18 @@ const realmRole = {
name: "realmRoles",
} as ClientRepresentation;
export const AddServiceAccountModal = ({
clientId,
serviceAccountId,
export const AddRoleMappingModal = ({
id,
name,
type,
onAssign,
onClose,
}: AddServiceAccountModalProps) => {
}: AddRoleMappingModalProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const errorHandler = useErrorHandler();
const [clients, setClients] = useState<ClientRole[]>([]);
const [name, setName] = useState<string>();
const [searchToggle, setSearchToggle] = useState(false);
const [key, setKey] = useState(0);
@ -66,16 +70,25 @@ export const AddServiceAccountModal = ({
asyncStateFetch(
async () => {
const clients = await adminClient.clients.find();
setName(clients.find((client) => client.id === clientId)?.clientId);
return (
await Promise.all(
clients.map(async (client) => {
const roles = await adminClient.users.listAvailableClientRoleMappings(
{
id: serviceAccountId,
clientUniqueId: client.id!,
}
);
let roles: RoleRepresentation[] = [];
if (type === "service-account") {
roles = await adminClient.users.listAvailableClientRoleMappings(
{
id: id,
clientUniqueId: client.id!,
}
);
} else if (type === "client-scope") {
roles = await adminClient.clientScopes.listAvailableClientScopeMappings(
{
id,
client: client.id!,
}
);
}
return {
roles,
client,
@ -114,11 +127,18 @@ export const AddServiceAccountModal = ({
(client) => client.name !== "realmRoles"
);
}
const realmRoles = (
await adminClient.users.listAvailableRealmRoleMappings({
id: serviceAccountId,
})
).map((role) => {
let availableRoles: RoleRepresentation[] = [];
if (type === "service-account") {
availableRoles = await adminClient.users.listAvailableRealmRoleMappings({
id,
});
} else if (type === "client-scope") {
availableRoles = await adminClient.clientScopes.listAvailableRealmScopeMappings(
{ id }
);
}
const realmRoles = availableRoles.map((role) => {
return {
role,
client: undefined,
@ -132,19 +152,27 @@ export const AddServiceAccountModal = ({
const roles = (
await Promise.all(
allClients.map(async (client) =>
(
await adminClient.users.listAvailableClientRoleMappings({
id: serviceAccountId,
clientUniqueId: client.id!,
})
).map((role) => {
allClients.map(async (client) => {
let clientAvailableRoles: RoleRepresentation[] = [];
if (type === "service-account") {
clientAvailableRoles = await adminClient.users.listAvailableClientRoleMappings(
{
id,
clientUniqueId: client.id!,
}
);
} else if (type === "client-scope") {
clientAvailableRoles = await adminClient.clientScopes.listAvailableClientScopeMappings(
{ id, client: client.id! }
);
}
return clientAvailableRoles.map((role) => {
return {
role,
client,
};
})
)
});
})
)
).flat();
@ -173,9 +201,7 @@ export const AddServiceAccountModal = ({
return (
<Modal
variant={ModalVariant.large}
title={t("assignRolesTo", {
client: name,
})}
title={t("assignRolesTo", { client: name })}
isOpen={true}
onClose={onClose}
actions={[

View file

@ -0,0 +1,307 @@
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 "../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 "./RoleMapping";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
export type MappingType = "service-account" | "client-scope";
type AddRoleMappingModalProps = {
id: string;
type: MappingType;
name: string;
onAssign: (rows: Row[]) => void;
onClose: () => void;
};
type ClientRole = ClientRepresentation & {
numberOfRoles: number;
};
const realmRole = {
name: "realmRoles",
} as ClientRepresentation;
export const AddRoleMappingModal = ({
id,
name,
type,
onAssign,
onClose,
}: AddRoleMappingModalProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const errorHandler = useErrorHandler();
const [clients, setClients] = useState<ClientRole[]>([]);
const [name, setName] = useState<string>();
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();
setName(clients.find((client) => client.id === clientId)?.clientId);
return (
await Promise.all(
clients.map(async (client) => {
let roles: RoleRepresentation[] = [];
if (type === "service-account") {
roles = await adminClient.users.listAvailableClientRoleMappings(
{
id: id,
clientUniqueId: client.id!,
}
);
} else if (type === "client-scope") {
roles = await adminClient.clientScopes.listAvailableClientScopeMappings(
{
id,
client: 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"
);
}
let availableRoles: RoleRepresentation[] = [];
if (type === "service-account") {
availableRoles = await adminClient.users.listAvailableRealmRoleMappings({
id,
});
} else if (type === "client-scope") {
availableRoles = await adminClient.clientScopes.listAvailableRealmScopeMappings(
{ id }
);
}
const realmRoles = availableRoles.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) => {
let clientAvailableRoles: RoleRepresentation[] = [];
if (type === "service-account") {
clientAvailableRoles = await adminClient.users.listAvailableClientRoleMappings(
{
id,
clientUniqueId: client.id!,
}
);
} else if (type === "client-scope") {
clientAvailableRoles = await adminClient.clientScopes.listAvailableClientScopeMappings(
{ id, client: client.id! }
);
}
return clientAvailableRoles.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}
<<<<<<< HEAD:src/clients/service-account/AddServiceAccountModal.tsx
title={t("assignRolesTo", {
client: name,
})}
=======
title={t("assignRolesTo", { client: name })}
>>>>>>> 0f6f6ab (fixed assign):src/components/role-mapping/AddRoleMappingModal.tsx
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="link"
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

@ -0,0 +1,211 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
Badge,
Button,
ButtonVariant,
Checkbox,
ToolbarItem,
} from "@patternfly/react-core";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import { AddRoleMappingModal, MappingType } from "./AddRoleMappingModal";
import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable";
import { emptyFormatter } from "../../util";
import "./role-mapping.css";
import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../alert/Alerts";
import _ from "lodash";
export type CompositeRole = RoleRepresentation & {
parent: RoleRepresentation;
};
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--role-mapping__client-name"
>
{client.clientId}
</Badge>
)}
{role.name}
</>
);
type RoleMappingProps = {
name: string;
id: string;
type: MappingType;
loader: () => Promise<Row[]>;
save: (rows: Row[]) => Promise<void>;
onHideRolesToggle: () => void;
};
export const RoleMapping = ({
name,
id,
type,
loader,
save,
onHideRolesToggle,
}: RoleMappingProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const [hide, setHide] = useState(false);
const [showAssign, setShowAssign] = useState(false);
const [selected, setSelected] = useState<Row[]>([]);
const assignRoles = async (rows: Row[]) => {
await save(rows);
refresh();
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "clients:removeMappingTitle",
messageKey: t("removeMappingConfirm", { count: selected.length }),
continueButtonLabel: "common:remove",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
if (type === "service-account") {
await Promise.all(
selected.map((row) => {
const role = { id: row.role.id!, name: row.role.name! };
if (row.client) {
return adminClient.users.delClientRoleMappings({
id,
clientUniqueId: row.client!.id!,
roles: [role],
});
} else {
return adminClient.users.delRealmRoleMappings({
id,
roles: [role],
});
}
})
);
} else if (type === "client-scope") {
await Promise.all(
selected.map((row) => {
const role = { id: row.role.id!, name: row.role.name! };
if (row.client) {
return adminClient.clientScopes.delClientScopeMappings(
{
id,
client: row.client!.id!,
},
[role]
);
} else {
return adminClient.clientScopes.delRealmScopeMappings(
{
id,
},
[role]
);
}
})
);
}
addAlert(t("clientScopeRemoveSuccess"), AlertVariant.success);
refresh();
} catch (error) {
addAlert(t("clientScopeRemoveError", { error }), AlertVariant.danger);
}
},
});
return (
<>
{showAssign && (
<AddRoleMappingModal
id={id}
type={type}
name={name}
onAssign={assignRoles}
onClose={() => setShowAssign(false)}
/>
)}
<DeleteConfirm />
<KeycloakDataTable
data-testid="assigned-roles"
key={key}
loader={loader}
canSelectAll={hide}
onSelect={hide ? (rows) => setSelected(rows) : undefined}
searchPlaceholderKey="clients:searchByName"
ariaLabelKey="clients:clientScopeList"
toolbarItem={
<>
<ToolbarItem>
<Checkbox
label={t("hideInheritedRoles")}
id="hideInheritedRoles"
isChecked={hide}
onChange={(check) => {
setHide(check);
onHideRolesToggle();
refresh();
}}
/>
</ToolbarItem>
<ToolbarItem>
<Button
data-testid="assignRole"
onClick={() => setShowAssign(true)}
>
{t("assignRole")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="link"
data-testid="unAssignRole"
onClick={toggleDeleteDialog}
isDisabled={selected.length === 0}
>
{t("unAssignRole")}
</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()],
},
]}
/>
</>
);
};

View file

@ -1,5 +1,5 @@
.keycloak-admin--service-account__client-name {
.keycloak-admin--role-mapping__client-name {
margin-right: var(--pf-global--spacer--sm);
}

View file

@ -173,10 +173,9 @@ export function KeycloakDataTable<T>({
}, [selected]);
useEffect(() => {
setLoading(true);
return asyncStateFetch(
async () => {
setLoading(true);
let data = unPaginatedData || (await loader(first, max, search));
if (!isPaginated) {

View file

@ -13471,10 +13471,10 @@ junk@^3.1.0:
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
keycloak-admin@1.14.10:
version "1.14.10"
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.10.tgz#e44903826896262b3655303db46795b84a5f9b08"
integrity sha512-WhEA+FkcPikN/Oqh7L0puVkPU1cm3bB+15VOoPdESZknQ9poS0Ohz3Rg1flRfmMdqoMgcy+prigUPtHy6gOAUg==
keycloak-admin@1.14.11:
version "1.14.11"
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.11.tgz#71415395eeb014f5a8675c951b23596ba33b6f35"
integrity sha512-s0NNLdJ27oAx52pXsvJgm8O/KDb0dbPsnbc+f4uTaz/Gzh6QN6GJPCgAYJEZj/Re+oOm+OVRHTx8bhhlrom5hA==
dependencies:
axios "^0.21.0"
camelize "^1.0.0"