Added mapping tab to clients (#1375)

* initial version mapper tab for clients

* added missing client select component

* added test

* Update src/client-scopes/add/components/ClientSelectComponent.tsx

Co-authored-by: Jon Koops <jonkoops@gmail.com>

* Update src/client-scopes/add/components/ClientSelectComponent.tsx

Co-authored-by: Jon Koops <jonkoops@gmail.com>

* now uses adminClient type for client search

Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Erik Jan de Wit 2021-10-20 16:26:05 +02:00 committed by GitHub
parent 82c04d2e4a
commit acd8921b20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 396 additions and 162 deletions

View file

@ -188,6 +188,36 @@ describe("Clients test", () => {
}); });
}); });
describe("Mapping tab", () => {
const mappingClient = "mapping-client";
beforeEach(() => {
keycloakBefore();
loginPage.logIn();
sidebarPage.goToClients();
listingPage.searchItem(mappingClient).goToItemDetails(mappingClient);
});
before(() => {
new AdminClient().createClient({
protocol: "openid-connect",
clientId: mappingClient,
publicClient: false,
});
});
after(() => {
new AdminClient().deleteClient(mappingClient);
});
it("add mapping to openid client", () => {
cy.get("#pf-tab-mappers-mappers").click();
cy.findByText("Add predefined mapper").click();
cy.get("table input").first().click();
cy.findByTestId("modalConfirm").click();
masthead.checkNotificationMessage("Mapping successfully created");
});
});
describe("Keys tab test", () => { describe("Keys tab test", () => {
const keysName = "keys-client"; const keysName = "keys-client";
beforeEach(() => { beforeEach(() => {

14
package-lock.json generated
View file

@ -7,7 +7,7 @@
"name": "keycloak-admin-ui", "name": "keycloak-admin-ui",
"license": "Apache", "license": "Apache",
"dependencies": { "dependencies": {
"@keycloak/keycloak-admin-client": "^16.0.0-dev.32", "@keycloak/keycloak-admin-client": "^16.0.0-dev.34",
"@patternfly/patternfly": "^4.144.5", "@patternfly/patternfly": "^4.144.5",
"@patternfly/react-code-editor": "^4.3.85", "@patternfly/react-code-editor": "^4.3.85",
"@patternfly/react-core": "^4.162.3", "@patternfly/react-core": "^4.162.3",
@ -3385,9 +3385,9 @@
} }
}, },
"node_modules/@keycloak/keycloak-admin-client": { "node_modules/@keycloak/keycloak-admin-client": {
"version": "16.0.0-dev.32", "version": "16.0.0-dev.34",
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.32.tgz", "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.34.tgz",
"integrity": "sha512-VGecbeyhwK9oKaNm4LvmryDG+JSZCzUFsbBjv3jwuhNd/nxIP8AXpKBz+8sw3I6GgJojC4wCgGIgfQPqLeLquQ==", "integrity": "sha512-Pd1s8l05QGtRgTrDPXi3BQXlyjGlOCdw4P8czNyJg5v7IyvqGuEhws2twRnFliiZFdZ1RwOzpchWfyv8E4LcCA==",
"dependencies": { "dependencies": {
"axios": "^0.21.0", "axios": "^0.21.0",
"camelize-ts": "^1.0.8", "camelize-ts": "^1.0.8",
@ -23701,9 +23701,9 @@
} }
}, },
"@keycloak/keycloak-admin-client": { "@keycloak/keycloak-admin-client": {
"version": "16.0.0-dev.32", "version": "16.0.0-dev.34",
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.32.tgz", "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.34.tgz",
"integrity": "sha512-VGecbeyhwK9oKaNm4LvmryDG+JSZCzUFsbBjv3jwuhNd/nxIP8AXpKBz+8sw3I6GgJojC4wCgGIgfQPqLeLquQ==", "integrity": "sha512-Pd1s8l05QGtRgTrDPXi3BQXlyjGlOCdw4P8czNyJg5v7IyvqGuEhws2twRnFliiZFdZ1RwOzpchWfyv8E4LcCA==",
"requires": { "requires": {
"axios": "^0.21.0", "axios": "^0.21.0",
"camelize-ts": "^1.0.8", "camelize-ts": "^1.0.8",

View file

@ -23,7 +23,7 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@keycloak/keycloak-admin-client": "^16.0.0-dev.32", "@keycloak/keycloak-admin-client": "^16.0.0-dev.34",
"@patternfly/patternfly": "^4.144.5", "@patternfly/patternfly": "^4.144.5",
"@patternfly/react-code-editor": "^4.3.85", "@patternfly/react-code-editor": "^4.3.85",
"@patternfly/react-core": "^4.162.3", "@patternfly/react-core": "^4.162.3",

View file

@ -0,0 +1,86 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import {
FormGroup,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import type { ClientQuery } from "@keycloak/keycloak-admin-client/lib/resources/clients";
import { useAdminClient, useFetch } from "../../../context/auth/AdminClient";
import { HelpItem } from "../../../components/help-enabler/HelpItem";
import type { ComponentProps } from "./components";
export const ClientSelectComponent = ({
name,
label,
helpText,
defaultValue,
}: ComponentProps) => {
const { t } = useTranslation("client-scopes");
const { control } = useFormContext();
const [open, setOpen] = useState(false);
const [clients, setClients] = useState<JSX.Element[]>();
const [search, setSearch] = useState("");
const adminClient = useAdminClient();
useFetch(
() => {
const params: ClientQuery = {
max: 20,
};
if (search) {
params.clientId = search;
params.search = true;
}
return adminClient.clients.find(params);
},
(clients) =>
setClients(
clients.map((option) => (
<SelectOption key={option.id} value={option.clientId} />
))
),
[search]
);
return (
<FormGroup
label={t(label!)}
labelIcon={
<HelpItem helpText={t(helpText!)} forLabel={t(label!)} forID={name!} />
}
fieldId={name!}
>
<Controller
name={`config.${name?.replaceAll(".", "-")}`}
defaultValue={defaultValue || ""}
control={control}
render={({ onChange, value }) => (
<Select
toggleId={name}
variant={SelectVariant.typeahead}
onToggle={(open) => setOpen(open)}
isOpen={open}
selections={value}
onFilter={(_, value) => {
setSearch(value);
return clients;
}}
onSelect={(_, value) => {
onChange(value.toString());
setOpen(false);
}}
aria-label={t(label!)}
>
{clients}
</Select>
)}
/>
</FormGroup>
);
};

View file

@ -6,9 +6,17 @@ import { BooleanComponent } from "./BooleanComponent";
import { ListComponent } from "./ListComponent"; import { ListComponent } from "./ListComponent";
import { RoleComponent } from "./RoleComponent"; import { RoleComponent } from "./RoleComponent";
import { ScriptComponent } from "./ScriptComponent"; import { ScriptComponent } from "./ScriptComponent";
import { ClientSelectComponent } from "./ClientSelectComponent";
export type ComponentProps = Omit<ConfigPropertyRepresentation, "type">; export type ComponentProps = Omit<ConfigPropertyRepresentation, "type">;
const ComponentTypes = ["String", "boolean", "List", "Role", "Script"] as const; const ComponentTypes = [
"String",
"boolean",
"List",
"Role",
"Script",
"ClientList",
] as const;
export type Components = typeof ComponentTypes[number]; export type Components = typeof ComponentTypes[number];
@ -20,4 +28,5 @@ export const COMPONENTS: {
List: ListComponent, List: ListComponent,
Role: RoleComponent, Role: RoleComponent,
Script: ScriptComponent, Script: ScriptComponent,
ClientList: ClientSelectComponent,
} as const; } as const;

View file

@ -1,31 +1,27 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useHistory } from "react-router-dom"; import type { LocationDescriptorObject } from "history";
import { import { Link } from "react-router-dom";
AlertVariant, import { Dropdown, DropdownItem, DropdownToggle } from "@patternfly/react-core";
Dropdown,
DropdownItem,
DropdownToggle,
} from "@patternfly/react-core";
import { CaretDownIcon } from "@patternfly/react-icons"; import { CaretDownIcon } from "@patternfly/react-icons";
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
import type ProtocolMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/protocolMapperRepresentation"; import type ProtocolMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/protocolMapperRepresentation";
import type { ProtocolMapperTypeRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/serverInfoRepesentation"; import type { ProtocolMapperTypeRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/serverInfoRepesentation";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider"; import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
import { useAlerts } from "../../components/alert/Alerts";
import { AddMapperDialog } from "../add/MapperDialog"; import { AddMapperDialog } from "../add/MapperDialog";
import { useAdminClient } from "../../context/auth/AdminClient";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { useRealm } from "../../context/realm-context/RealmContext";
import { toMapper } from "../routes/Mapper";
type MapperListProps = { type MapperListProps = {
clientScope: ClientScopeRepresentation; model: ClientScopeRepresentation | ClientRepresentation;
type: string; onAdd: (
refresh: () => void; mappers: ProtocolMapperTypeRepresentation | ProtocolMapperRepresentation[]
) => void;
onDelete: (mapper: ProtocolMapperRepresentation) => void;
detailLink: (id: string) => LocationDescriptorObject;
}; };
type Row = ProtocolMapperRepresentation & { type Row = ProtocolMapperRepresentation & {
@ -34,24 +30,23 @@ type Row = ProtocolMapperRepresentation & {
priority: number; priority: number;
}; };
export const MapperList = ({ clientScope, type, refresh }: MapperListProps) => { export const MapperList = ({
model,
onAdd,
onDelete,
detailLink,
}: MapperListProps) => {
const { t } = useTranslation("client-scopes"); const { t } = useTranslation("client-scopes");
const adminClient = useAdminClient();
const { addAlert, addError } = useAlerts();
const history = useHistory();
const { realm } = useRealm();
const [mapperAction, setMapperAction] = useState(false); const [mapperAction, setMapperAction] = useState(false);
const mapperList = clientScope.protocolMappers; const mapperList = model.protocolMappers;
const mapperTypes = const mapperTypes = useServerInfo().protocolMapperTypes![model.protocol!];
useServerInfo().protocolMapperTypes![clientScope.protocol!];
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
useEffect(() => setKey(new Date().getTime()), [mapperList]); useEffect(() => setKey(key + 1), [mapperList]);
const [addMapperDialogOpen, setAddMapperDialogOpen] = useState(false); const [addMapperDialogOpen, setAddMapperDialogOpen] = useState(false);
const [filter, setFilter] = useState(clientScope.protocolMappers); const [filter, setFilter] = useState(model.protocolMappers);
const toggleAddMapperDialog = (buildIn: boolean) => { const toggleAddMapperDialog = (buildIn: boolean) => {
if (buildIn) { if (buildIn) {
setFilter(mapperList || []); setFilter(mapperList || []);
@ -61,33 +56,6 @@ export const MapperList = ({ clientScope, type, refresh }: MapperListProps) => {
setAddMapperDialogOpen(!addMapperDialogOpen); setAddMapperDialogOpen(!addMapperDialogOpen);
}; };
const addMappers = async (
mappers: ProtocolMapperTypeRepresentation | ProtocolMapperRepresentation[]
): Promise<void> => {
if (filter === undefined) {
const mapper = mappers as ProtocolMapperTypeRepresentation;
history.push(
toMapper({
realm,
id: clientScope.id!,
type,
mapperId: mapper.id!,
})
);
} else {
try {
await adminClient.clientScopes.addMultipleProtocolMappers(
{ id: clientScope.id! },
mappers as ProtocolMapperRepresentation[]
);
refresh();
addAlert(t("common:mappingCreatedSuccess"), AlertVariant.success);
} catch (error) {
addError("common:mappingCreatedError", error);
}
}
};
const loader = async () => const loader = async () =>
Promise.resolve( Promise.resolve(
(mapperList || []) (mapperList || [])
@ -106,17 +74,15 @@ export const MapperList = ({ clientScope, type, refresh }: MapperListProps) => {
); );
const MapperLink = ({ id, name }: Row) => ( const MapperLink = ({ id, name }: Row) => (
<Link to={toMapper({ realm, id: clientScope.id!, type, mapperId: id! })}> <Link to={detailLink(id!)}>{name}</Link>
{name}
</Link>
); );
return ( return (
<> <>
<AddMapperDialog <AddMapperDialog
protocol={clientScope.protocol!} protocol={model.protocol!}
filter={filter} filter={filter}
onConfirm={addMappers} onConfirm={onAdd}
open={addMapperDialogOpen} open={addMapperDialogOpen}
toggleDialog={() => setAddMapperDialogOpen(!addMapperDialogOpen)} toggleDialog={() => setAddMapperDialogOpen(!addMapperDialogOpen)}
/> />
@ -159,22 +125,7 @@ export const MapperList = ({ clientScope, type, refresh }: MapperListProps) => {
actions={[ actions={[
{ {
title: t("common:delete"), title: t("common:delete"),
onRowClick: async (mapper) => { onRowClick: onDelete,
try {
await adminClient.clientScopes.delProtocolMapper({
id: clientScope.id!,
mapperId: mapper.id!,
});
addAlert(
t("common:mappingDeletedSuccess"),
AlertVariant.success
);
refresh();
} catch (error) {
addError("common:mappingDeletedError", error);
}
return true;
},
}, },
]} ]}
columns={[ columns={[

View file

@ -1,5 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Link, useHistory, useParams } from "react-router-dom"; import { Link, useHistory, useParams, useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { import {
@ -25,11 +25,11 @@ import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { convertFormValuesToObject, convertToFormValues } from "../../util"; import { convertFormValuesToObject, convertToFormValues } from "../../util";
import { FormAccess } from "../../components/form-access/FormAccess"; import { FormAccess } from "../../components/form-access/FormAccess";
import { useRealm } from "../../context/realm-context/RealmContext"; import { useRealm } from "../../context/realm-context/RealmContext";
import type { MapperParams } from "../routes/Mapper"; import { MapperParams, MapperRoute } from "../routes/Mapper";
import { Components, COMPONENTS } from "../add/components/components"; import { Components, COMPONENTS } from "../add/components/components";
import { toClientScope } from "../routes/ClientScope";
import "./mapping-details.css"; import "./mapping-details.css";
import { toClientScope } from "../routes/ClientScope";
export const MappingDetails = () => { export const MappingDetails = () => {
const { t } = useTranslation("client-scopes"); const { t } = useTranslation("client-scopes");
@ -47,15 +47,29 @@ export const MappingDetails = () => {
const { realm } = useRealm(); const { realm } = useRealm();
const serverInfo = useServerInfo(); const serverInfo = useServerInfo();
const isGuid = /^[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?$/; const isGuid = /^[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?$/;
const isUpdating = mapperId.match(isGuid); const isUpdating = !!mapperId.match(isGuid);
const isOnClientScope = !!useRouteMatch(MapperRoute.path);
const toDetails = () =>
isOnClientScope
? toClientScope({ realm, id, type: type!, tab: "mappers" })
: `/${realm}/clients/${id}/mappers`;
useFetch( useFetch(
async () => { async () => {
let data: ProtocolMapperRepresentation | undefined;
if (isUpdating) { if (isUpdating) {
const data = await adminClient.clientScopes.findProtocolMapper({ if (isOnClientScope) {
data = await adminClient.clientScopes.findProtocolMapper({
id, id,
mapperId, mapperId,
}); });
} else {
data = await adminClient.clients.findProtocolMapperById({
id,
mapperId,
});
}
if (!data) { if (!data) {
throw new Error(t("common:notFound")); throw new Error(t("common:notFound"));
} }
@ -74,12 +88,14 @@ export const MappingDetails = () => {
data, data,
}; };
} else { } else {
const scope = await adminClient.clientScopes.findOne({ id }); const model = type
if (!scope) { ? await adminClient.clientScopes.findOne({ id })
: await adminClient.clients.findOne({ id });
if (!model) {
throw new Error(t("common:notFound")); throw new Error(t("common:notFound"));
} }
const protocolMappers = const protocolMappers =
serverInfo.protocolMapperTypes![scope.protocol!]; serverInfo.protocolMapperTypes![model.protocol!];
const mapping = protocolMappers.find( const mapping = protocolMappers.find(
(mapper) => mapper.id === mapperId (mapper) => mapper.id === mapperId
); );
@ -89,7 +105,7 @@ export const MappingDetails = () => {
return { return {
mapping, mapping,
config: { config: {
protocol: scope.protocol, protocol: model.protocol,
protocolMapper: mapperId, protocolMapper: mapperId,
}, },
}; };
@ -117,12 +133,19 @@ export const MappingDetails = () => {
continueButtonVariant: ButtonVariant.danger, continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => { onConfirm: async () => {
try { try {
if (isOnClientScope) {
await adminClient.clientScopes.delProtocolMapper({ await adminClient.clientScopes.delProtocolMapper({
id, id,
mapperId: mapperId, mapperId,
}); });
} else {
await adminClient.clients.delProtocolMapper({
id,
mapperId,
});
}
addAlert(t("common:mappingDeletedSuccess"), AlertVariant.success); addAlert(t("common:mappingDeletedSuccess"), AlertVariant.success);
history.push(`/${realm}/client-scopes/${id}/mappers`); history.push(toDetails());
} catch (error) { } catch (error) {
addError("common:mappingDeletedError", error); addError("common:mappingDeletedError", error);
} }
@ -133,16 +156,21 @@ export const MappingDetails = () => {
const configAttributes = convertFormValuesToObject(formMapping.config); const configAttributes = convertFormValuesToObject(formMapping.config);
const key = isUpdating ? "Updated" : "Created"; const key = isUpdating ? "Updated" : "Created";
try { try {
const mapping = { ...formMapping, ...config, config: configAttributes };
if (isUpdating) { if (isUpdating) {
await adminClient.clientScopes.updateProtocolMapper( isOnClientScope
? await adminClient.clientScopes.updateProtocolMapper(
{ id, mapperId }, { id, mapperId },
{ ...formMapping, config: configAttributes } { id: mapperId, ...mapping }
)
: await adminClient.clients.updateProtocolMapper(
{ id, mapperId },
{ id: mapperId, ...mapping }
); );
} else { } else {
await adminClient.clientScopes.addProtocolMapper( isOnClientScope
{ id }, ? await adminClient.clientScopes.addProtocolMapper({ id }, mapping)
{ ...formMapping, ...config, config: configAttributes } : await adminClient.clients.addProtocolMapper({ id }, mapping);
);
} }
addAlert(t(`common:mapping${key}Success`), AlertVariant.success); addAlert(t(`common:mapping${key}Success`), AlertVariant.success);
} catch (error) { } catch (error) {
@ -180,8 +208,6 @@ export const MappingDetails = () => {
role="manage-clients" role="manage-clients"
className="keycloak__client-scope-mapping-details__form" className="keycloak__client-scope-mapping-details__form"
> >
{!mapperId.match(isGuid) && (
<>
<FormGroup label={t("common:mapperType")} fieldId="mapperType"> <FormGroup label={t("common:mapperType")} fieldId="mapperType">
<TextInput <TextInput
type="text" type="text"
@ -203,9 +229,7 @@ export const MappingDetails = () => {
fieldId="name" fieldId="name"
isRequired isRequired
validated={ validated={
errors.name errors.name ? ValidatedOptions.error : ValidatedOptions.default
? ValidatedOptions.error
: ValidatedOptions.default
} }
helperTextInvalid={t("common:required")} helperTextInvalid={t("common:required")}
> >
@ -214,15 +238,12 @@ export const MappingDetails = () => {
type="text" type="text"
id="name" id="name"
name="name" name="name"
isReadOnly={isUpdating}
validated={ validated={
errors.name errors.name ? ValidatedOptions.error : ValidatedOptions.default
? ValidatedOptions.error
: ValidatedOptions.default
} }
/> />
</FormGroup> </FormGroup>
</>
)}
<FormProvider {...form}> <FormProvider {...form}>
{mapping?.properties.map((property) => { {mapping?.properties.map((property) => {
const componentType = property.type!; const componentType = property.type!;
@ -242,12 +263,7 @@ export const MappingDetails = () => {
</Button> </Button>
<Button <Button
variant="link" variant="link"
component={(props) => ( component={(props) => <Link {...props} to={toDetails()} />}
<Link
{...props}
to={toClientScope({ realm, id, type, tab: "mappers" })}
/>
)}
> >
{t("common:cancel")} {t("common:cancel")}
</Button> </Button>

View file

@ -25,12 +25,15 @@ import {
Row, Row,
} from "../../components/role-mapping/RoleMapping"; } from "../../components/role-mapping/RoleMapping";
import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
import type { ProtocolMapperTypeRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/serverInfoRepesentation";
import type ProtocolMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/protocolMapperRepresentation";
import { import {
AllClientScopes, AllClientScopes,
changeScope, changeScope,
ClientScopeDefaultOptionalType, ClientScopeDefaultOptionalType,
} from "../../components/client-scope/ClientScopeTypes"; } from "../../components/client-scope/ClientScopeTypes";
import { useRealm } from "../../context/realm-context/RealmContext"; import { useRealm } from "../../context/realm-context/RealmContext";
import { toMapper } from "../routes/Mapper";
export const ClientScopeForm = () => { export const ClientScopeForm = () => {
const { t } = useTranslation("client-scopes"); const { t } = useTranslation("client-scopes");
@ -187,6 +190,47 @@ export const ClientScopeForm = () => {
} }
}; };
const addMappers = async (
mappers: ProtocolMapperTypeRepresentation | ProtocolMapperRepresentation[]
): Promise<void> => {
if (!Array.isArray(mappers)) {
const mapper = mappers as ProtocolMapperTypeRepresentation;
history.push(
toMapper({
realm,
id: clientScope!.id!,
type,
mapperId: mapper.id!,
})
);
} else {
try {
await adminClient.clientScopes.addMultipleProtocolMappers(
{ id: clientScope!.id! },
mappers as ProtocolMapperRepresentation[]
);
refresh();
addAlert(t("common:mappingCreatedSuccess"), AlertVariant.success);
} catch (error) {
addError("common:mappingCreatedError", error);
}
}
};
const onDelete = async (mapper: ProtocolMapperRepresentation) => {
try {
await adminClient.clientScopes.delProtocolMapper({
id: clientScope!.id!,
mapperId: mapper.id!,
});
addAlert(t("common:mappingDeletedSuccess"), AlertVariant.success);
refresh();
} catch (error) {
addError("common:mappingDeletedError", error);
}
return true;
};
if (id && !clientScope) { if (id && !clientScope) {
return ( return (
<div className="pf-u-text-align-center"> <div className="pf-u-text-align-center">
@ -236,9 +280,12 @@ export const ClientScopeForm = () => {
title={<TabTitleText>{t("common:mappers")}</TabTitleText>} title={<TabTitleText>{t("common:mappers")}</TabTitleText>}
> >
<MapperList <MapperList
clientScope={clientScope} model={clientScope}
type={type} onAdd={addMappers}
refresh={refresh} onDelete={onDelete}
detailLink={(id) =>
toMapper({ realm, id: clientScope.id!, type, mapperId: id! })
}
/> />
</Tab> </Tab>
<Tab <Tab

View file

@ -54,6 +54,10 @@ import { EvaluateScopes } from "./scopes/EvaluateScopes";
import { ServiceAccount } from "./service-account/ServiceAccount"; import { ServiceAccount } from "./service-account/ServiceAccount";
import { isRealmClient, getProtocolName } from "./utils"; import { isRealmClient, getProtocolName } from "./utils";
import { SamlKeys } from "./keys/SamlKeys"; import { SamlKeys } from "./keys/SamlKeys";
import { MapperList } from "../client-scopes/details/MapperList";
import type { ProtocolMapperTypeRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/serverInfoRepesentation";
import type ProtocolMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/protocolMapperRepresentation";
import { toMapper } from "./routes/Mapper";
type ClientDetailHeaderProps = { type ClientDetailHeaderProps = {
onChange: (value: boolean) => void; onChange: (value: boolean) => void;
@ -279,6 +283,51 @@ export const ClientDetails = () => {
} }
}; };
const addMappers = async (
mappers: ProtocolMapperTypeRepresentation | ProtocolMapperRepresentation[]
): Promise<void> => {
if (!Array.isArray(mappers)) {
const mapper = mappers as ProtocolMapperTypeRepresentation;
history.push(
toMapper({
realm,
id: client!.id!,
mapperId: mapper.id!,
})
);
} else {
try {
await adminClient.clients.addMultipleProtocolMappers(
{ id: client!.id! },
mappers as ProtocolMapperRepresentation[]
);
setClient(await adminClient.clients.findOne({ id: client!.id! }));
addAlert(t("common:mappingCreatedSuccess"), AlertVariant.success);
} catch (error) {
addError("common:mappingCreatedError", error);
}
}
};
const onDeleteMapper = async (mapper: ProtocolMapperRepresentation) => {
try {
await adminClient.clients.delProtocolMapper({
id: client!.id!,
mapperId: mapper.id!,
});
setClient({
...client,
protocolMappers: client?.protocolMappers?.filter(
(m) => m.id !== mapper.id
),
});
addAlert(t("common:mappingDeletedSuccess"), AlertVariant.success);
} catch (error) {
addError("common:mappingDeletedError", error);
}
return true;
};
if (!client) { if (!client) {
return ( return (
<div className="pf-u-text-align-center"> <div className="pf-u-text-align-center">
@ -367,6 +416,22 @@ export const ClientDetails = () => {
<Credentials clientId={clientId} save={() => save()} /> <Credentials clientId={clientId} save={() => save()} />
</Tab> </Tab>
)} )}
{!isRealmClient(client) && (
<Tab
id="mappers"
eventKey="mappers"
title={<TabTitleText>{t("mappers")}</TabTitleText>}
>
<MapperList
model={client}
onAdd={addMappers}
onDelete={onDeleteMapper}
detailLink={(mapperId) =>
toMapper({ realm, id: client.id!, mapperId })
}
/>
</Tab>
)}
<Tab <Tab
id="roles" id="roles"
eventKey="roles" eventKey="roles"

View file

@ -10,6 +10,7 @@ import {
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { cellWidth, IRowData, TableText } from "@patternfly/react-table"; import { cellWidth, IRowData, TableText } from "@patternfly/react-table";
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import type { ClientQuery } from "@keycloak/keycloak-admin-client/lib/resources/clients";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -44,13 +45,13 @@ export const ClientsSection = () => {
const [selectedClient, setSelectedClient] = useState<ClientRepresentation>(); const [selectedClient, setSelectedClient] = useState<ClientRepresentation>();
const loader = async (first?: number, max?: number, search?: string) => { const loader = async (first?: number, max?: number, search?: string) => {
const params: { [name: string]: string | number } = { const params: ClientQuery = {
first: first!, first: first!,
max: max!, max: max!,
}; };
if (search) { if (search) {
params.clientId = search; params.clientId = search;
params.search = "true"; params.search = true;
} }
return await adminClient.clients.find({ ...params }); return await adminClient.clients.find({ ...params });
}; };

View file

@ -294,5 +294,6 @@ export default {
expires: "Expires in", expires: "Expires in",
never: "Never expires", never: "Never expires",
}, },
mappers: "Mappers",
}, },
}; };

View file

@ -4,6 +4,7 @@ import { ClientRoute } from "./routes/Client";
import { ClientsRoute } from "./routes/Clients"; import { ClientsRoute } from "./routes/Clients";
import { CreateInitialAccessTokenRoute } from "./routes/CreateInitialAccessToken"; import { CreateInitialAccessTokenRoute } from "./routes/CreateInitialAccessToken";
import { ImportClientRoute } from "./routes/ImportClient"; import { ImportClientRoute } from "./routes/ImportClient";
import { MapperRoute } from "./routes/Mapper";
const routes: RouteDef[] = [ const routes: RouteDef[] = [
AddClientRoute, AddClientRoute,
@ -11,6 +12,7 @@ const routes: RouteDef[] = [
ClientsRoute, ClientsRoute,
CreateInitialAccessTokenRoute, CreateInitialAccessTokenRoute,
ClientRoute, ClientRoute,
MapperRoute,
]; ];
export default routes; export default routes;

View file

@ -3,7 +3,12 @@ import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config"; import type { RouteDef } from "../../route-config";
import { ClientDetails } from "../ClientDetails"; import { ClientDetails } from "../ClientDetails";
export type ClientTab = "settings" | "roles" | "clientScopes" | "advanced"; export type ClientTab =
| "settings"
| "roles"
| "clientScopes"
| "advanced"
| "mappers";
export type ClientParams = { export type ClientParams = {
realm: string; realm: string;

View file

@ -0,0 +1,21 @@
import type { LocationDescriptorObject } from "history";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { MappingDetails } from "../../client-scopes/details/MappingDetails";
export type MapperParams = {
realm: string;
id: string;
mapperId: string;
};
export const MapperRoute: RouteDef = {
path: "/:realm/clients/:id/mappers/:mapperId",
component: MappingDetails,
breadcrumb: (t) => t("common:mappingDetails"),
access: "view-clients",
};
export const toMapper = (params: MapperParams): LocationDescriptorObject => ({
pathname: generatePath(MapperRoute.path, params),
});