Adding Mapping list and detail screen (#180)

* added client scope details screen

* initial version of the mapping list

* added tabs

* changed route create

* merge

* added detail mapper page

* fixed merge errors

* fix merge error

* dynamic title

* added access types to routs
This commit is contained in:
Erik Jan de Wit 2020-10-21 19:38:11 +02:00 committed by GitHub
parent a9dc031fff
commit 4195e0fbf3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 573 additions and 200 deletions

View file

@ -56,11 +56,7 @@ export const ClientScopesSection = () => {
inputGroupPlaceholder={t("searchFor")}
inputGroupOnChange={filterData}
toolbarItem={
<Button
onClick={() =>
history.push("/client-scopes/add-client-scopes/")
}
>
<Button onClick={() => history.push("/client-scopes/new")}>
{t("createClientScope")}
</Button>
}

View file

@ -2,6 +2,7 @@ import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
ButtonVariant,
Dropdown,
DropdownItem,
DropdownToggle,
@ -24,13 +25,14 @@ import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState
import { HttpClientContext } from "../../context/http-service/HttpClientContext";
import { RealmContext } from "../../context/realm-context/RealmContext";
import { useAlerts } from "../../components/alert/Alerts";
import { Link } from "react-router-dom";
type MapperListProps = {
clientScope: ClientScopeRepresentation;
};
type Row = {
name: string;
name: JSX.Element;
category: string;
type: string;
priority: number;
@ -58,6 +60,13 @@ export const MapperList = ({ clientScope }: MapperListProps) => {
instructions={t("emptyMappersInstructions")}
primaryActionText={t("emptyPrimaryAction")}
onPrimaryAction={() => {}}
secondaryActions={[
{
text: t("emptySecondaryAction"),
onClick: () => {},
type: ButtonVariant.secondary,
},
]}
/>
);
}
@ -70,7 +79,13 @@ export const MapperList = ({ clientScope }: MapperListProps) => {
return {
mapper,
cells: {
name: mapper.name,
name: (
<>
<Link to={`/client-scopes/${clientScope.id}/${mapper.id}`}>
{mapper.name}
</Link>
</>
),
category: mapperType.category,
type: mapperType.name,
priority: mapperType.priority,
@ -82,7 +97,7 @@ export const MapperList = ({ clientScope }: MapperListProps) => {
const filterData = (search: string) => {
setFilteredData(
data.filter((column) =>
column.cells.name.toLowerCase().includes(search.toLowerCase())
column.mapper.name!.toLowerCase().includes(search.toLowerCase())
)
);
};

View file

@ -0,0 +1,293 @@
import React, { useContext, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ActionGroup,
AlertVariant,
Button,
ButtonVariant,
Checkbox,
DropdownItem,
Flex,
FlexItem,
Form,
FormGroup,
PageSection,
Select,
SelectOption,
SelectVariant,
Switch,
TextInput,
} from "@patternfly/react-core";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { HttpClientContext } from "../../context/http-service/HttpClientContext";
import { RealmContext } from "../../context/realm-context/RealmContext";
import { ProtocolMapperRepresentation } from "../models/client-scope";
import { Controller, useForm } from "react-hook-form";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { useAlerts } from "../../components/alert/Alerts";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { ConfigPropertyRepresentation } from "../../context/server-info/server-info";
export const MappingDetails = () => {
const { t } = useTranslation("client-scopes");
const httpClient = useContext(HttpClientContext)!;
const { realm } = useContext(RealmContext);
const { addAlert } = useAlerts();
const { scopeId, id } = useParams<{ scopeId: string; id: string }>();
const { register, setValue, control, handleSubmit } = useForm();
const [mapping, setMapping] = useState<ProtocolMapperRepresentation>();
const [typeOpen, setTypeOpen] = useState(false);
const [configProperties, setConfigProperties] = useState<
ConfigPropertyRepresentation[]
>();
const serverInfo = useServerInfo();
const url = `/admin/realms/${realm}/client-scopes/${scopeId}/protocol-mappers/models/${id}`;
useEffect(() => {
(async () => {
const response = await httpClient.doGet<ProtocolMapperRepresentation>(
url
);
if (response.data) {
Object.entries(response.data).map((entry) => {
if (entry[0] === "config") {
Object.keys(entry[1]).map((key) => {
const newKey = key.replace(/\./g, "_");
setValue("config." + newKey, entry[1][key]);
});
}
setValue(entry[0], entry[1]);
});
}
setMapping(response.data);
const mapperTypes =
serverInfo.protocolMapperTypes[response.data!.protocol!];
const properties = mapperTypes.find(
(type) => type.id === mapping?.protocolMapper
)?.properties;
setConfigProperties(properties);
})();
}, []);
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "client-scopes:deleteMappingTitle",
messageKey: "client-scopes:deleteMappingConfirm",
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: () => {
try {
httpClient.doDelete(url);
addAlert(t("mappingDeletedSuccess"), AlertVariant.success);
} catch (error) {
addAlert(t("mappingDeletedError", { error }), AlertVariant.danger);
}
},
});
const save = async (formMapping: ProtocolMapperRepresentation) => {
const keyValues = Object.keys(formMapping.config!).map((key) => {
const newKey = key.replace(/_/g, ".");
return { [newKey]: formMapping.config![key] };
});
const map = { ...mapping, config: Object.assign({}, ...keyValues) };
try {
await httpClient.doPut(url, map);
addAlert(t("mappingUpdatedSuccess"), AlertVariant.success);
} catch (error) {
addAlert(t("mappingUpdatedError", { error }), AlertVariant.danger);
}
};
return (
<>
<DeleteConfirm />
<ViewHeader
titleKey={mapping ? mapping.name! : ""}
subKey={id}
badge={mapping?.protocol}
dropdownItems={[
<DropdownItem
key="delete"
value="delete"
onClick={toggleDeleteDialog}
>
{t("common:delete")}
</DropdownItem>,
]}
/>
<PageSection variant="light">
<Form isHorizontal onSubmit={handleSubmit(save)}>
<FormGroup
label={t("realmRolePrefix")}
labelIcon={
<HelpItem
helpText="client-scopes-help:prefix"
forLabel={t("realmRolePrefix")}
forID="prefix"
/>
}
fieldId="prefix"
>
<TextInput
ref={register()}
type="text"
id="prefix"
name="config.usermodel_realmRoleMapping_rolePrefix"
/>
</FormGroup>
<FormGroup
label={t("multiValued")}
labelIcon={
<HelpItem
helpText="client-scopes-help:multiValued"
forLabel={t("multiValued")}
forID="multiValued"
/>
}
fieldId="multiValued"
>
<Controller
name="config.multivalued"
control={control}
defaultValue="false"
render={({ onChange, value }) => (
<Switch
id="multiValued"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("tokenClaimName")}
labelIcon={
<HelpItem
helpText="client-scopes-help:tokenClaimName"
forLabel={t("tokenClaimName")}
forID="claimName"
/>
}
fieldId="claimName"
>
<TextInput
ref={register()}
type="text"
id="claimName"
name="config.claim_name"
/>
</FormGroup>
<FormGroup
label={t("claimJsonType")}
labelIcon={
<HelpItem
helpText="client-scopes-help:claimJsonType"
forLabel={t("claimJsonType")}
forID="claimJsonType"
/>
}
fieldId="claimJsonType"
>
<Controller
name="config.jsonType_label"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="claimJsonType"
onToggle={() => setTypeOpen(!typeOpen)}
onSelect={(_, value) => {
onChange(value as string);
setTypeOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("claimJsonType")}
isOpen={typeOpen}
>
{configProperties &&
configProperties
.find((property) => property.name === "jsonType.label")
?.options.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
/>
))}
</Select>
)}
/>
</FormGroup>
<FormGroup
hasNoPaddingTop
label={t("addClaimTo")}
fieldId="addClaimTo"
>
<Flex>
<FlexItem>
<Controller
name="config.id_token_claim"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Checkbox
label={t("idToken")}
id="idToken"
isChecked={value}
onChange={onChange}
/>
)}
/>
</FlexItem>
<FlexItem>
<Controller
name="config.access_token_claim"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Checkbox
label={t("accessToken")}
id="accessToken"
isChecked={value}
onChange={onChange}
/>
)}
/>
</FlexItem>
<FlexItem>
<Controller
name="config.userinfo_token_claim"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Checkbox
label={t("userInfo")}
id="userInfo"
isChecked={value}
onChange={onChange}
/>
)}
/>
</FlexItem>
</Flex>
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button variant="link">{t("common:cancel")}</Button>
</ActionGroup>
</Form>
</PageSection>
</>
);
};

View file

@ -11,6 +11,9 @@ import {
SelectOption,
SelectVariant,
Switch,
Tab,
Tabs,
TabTitleText,
TextInput,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
@ -24,14 +27,16 @@ 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 { MapperList } from "../details/MapperList";
export const ClientScopeForm = () => {
const { t } = useTranslation("client-scopes");
const helpText = useTranslation("client-scopes-help").t;
const { register, control, handleSubmit, errors, setValue } = useForm<
ClientScopeRepresentation
>();
const history = useHistory();
const [clientScope, setClientScope] = useState<ClientScopeRepresentation>();
const [activeTab, setActiveTab] = useState(0);
const httpClient = useContext(HttpClientContext)!;
const { realm } = useContext(RealmContext);
@ -55,6 +60,8 @@ export const ClientScopeForm = () => {
setValue(entry[0], entry[1]);
});
}
setClientScope(response.data);
}
})();
}, []);
@ -83,17 +90,29 @@ export const ClientScopeForm = () => {
return (
<>
<ViewHeader
titleKey="client-scopes:createClientScope"
titleKey={
clientScope ? clientScope.name! : "client-scopes:createClientScope"
}
subKey="client-scopes:clientScopeExplain"
badge={clientScope ? clientScope.protocol : undefined}
/>
<PageSection variant="light">
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
isBox
>
<Tab
eventKey={0}
title={<TabTitleText>{t("settings")}</TabTitleText>}
>
<Form isHorizontal onSubmit={handleSubmit(save)}>
<FormGroup
label={t("name")}
labelIcon={
<HelpItem
helpText={helpText("name")}
helpText="client-scopes-help:name"
forLabel={t("name")}
forID="kc-name"
/>
@ -114,7 +133,7 @@ export const ClientScopeForm = () => {
label={t("description")}
labelIcon={
<HelpItem
helpText={helpText("description")}
helpText="client-scopes-help:description"
forLabel={t("description")}
forID="kc-description"
/>
@ -128,11 +147,12 @@ export const ClientScopeForm = () => {
name="description"
/>
</FormGroup>
{!id && (
<FormGroup
label={t("protocol")}
labelIcon={
<HelpItem
helpText={helpText("protocol")}
helpText="client-scopes-help:protocol"
forLabel="protocol"
forID="kc-protocol"
/>
@ -169,12 +189,13 @@ export const ClientScopeForm = () => {
)}
/>
</FormGroup>
)}
<FormGroup
hasNoPaddingTop
label={t("displayOnConsentScreen")}
labelIcon={
<HelpItem
helpText={helpText("displayOnConsentScreen")}
helpText="client-scopes-help:displayOnConsentScreen"
forLabel={t("displayOnConsentScreen")}
forID="kc-display.on.consent.screen"
/>
@ -184,14 +205,14 @@ export const ClientScopeForm = () => {
<Controller
name="attributes.display_on_consent_screen"
control={control}
defaultValue={false}
defaultValue="false"
render={({ onChange, value }) => (
<Switch
id="kc-display.on.consent.screen"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
@ -200,7 +221,7 @@ export const ClientScopeForm = () => {
label={t("consentScreenText")}
labelIcon={
<HelpItem
helpText={helpText("consentScreenText")}
helpText="client-scopes-help:consentScreenText"
forLabel={t("consentScreenText")}
forID="kc-consent-screen-text"
/>
@ -219,7 +240,7 @@ export const ClientScopeForm = () => {
label={t("includeInTokenScope")}
labelIcon={
<HelpItem
helpText={helpText("includeInTokenScope")}
helpText="client-scopes-help:includeInTokenScope"
forLabel={t("includeInTokenScope")}
forID="includeInTokenScope"
/>
@ -245,7 +266,7 @@ export const ClientScopeForm = () => {
label={t("guiOrder")}
labelIcon={
<HelpItem
helpText={helpText("guiOrder")}
helpText="client-scopes-help:guiOrder"
forLabel={t("guiOrder")}
forID="kc-gui-order"
/>
@ -271,6 +292,11 @@ export const ClientScopeForm = () => {
</Button>
</ActionGroup>
</Form>
</Tab>
<Tab eventKey={1} title={<TabTitleText>{t("mappers")}</TabTitleText>}>
{clientScope && <MapperList clientScope={clientScope} />}
</Tab>
</Tabs>
</PageSection>
</>
);

View file

@ -6,6 +6,10 @@
"displayOnConsentScreen": "If on, and this client scope is added to some client with consent required, the text specified by 'Consent Screen Text' will be displayed on consent screen. If off, this client scope will not be displayed on the consent screen",
"consentScreenText": "Text that will be shown on the consent screen when this client scope is added to some client with consent required. Defaults to name of client scope if it is not filled",
"includeInTokenScope": "If on, the name of this client scope will be added to the access token property 'scope' as well as to the Token Introspection Endpoint response. If off, this client scope will be omitted from the token and from the Token Introspection Endpoint response.",
"guiOrder": "Specify order of the provider in GUI (such as in Consent page) as integer"
"guiOrder": "Specify order of the provider in GUI (such as in Consent page) as integer",
"prefix": "A prefix for each Realm Role (optional).",
"multiValued": "Indicates if attribute supports multiple values. If true, the list of all values of this attribute will be set as claim. If false, just first value will be set as claim",
"tokenClaimName": "Name of the claim to insert into the token. This can be a fully qualified name like 'address.street'. In this case, a nested json object will be created. To prevent nesting and use dot literally, escape the dot with backslash (\\.).",
"claimJsonType": "JSON type that should be used to populate the json claim in the token. long, int, boolean, String and JSON are valid values."
}
}

View file

@ -12,6 +12,31 @@
"priority": "Priority",
"protocol": "Protocol",
"includeInTokenScope": "Include in token scope",
"settings": "Settings",
"mappers": "Mappers",
"mappersSearchFor": "Search for mapper",
"addMapper": "Add mapper",
"fromPredefinedMapper": "From predefined mappers",
"byConfiguration": "By configuration",
"emptyMappers": "No mappers",
"emptyMappersInstructions": "If you want to add mappers, please click the button below to add some predefined mappers or to configure a new mapper.",
"emptyPrimaryAction": "Add predefined mapper",
"emptySecondaryAction": "Configure a new mapper",
"mappingDetails": "Mapper details",
"deleteMappingTitle": "Delete mapping?",
"deleteMappingConfirm": "Are you sure you want to delete this mapping?",
"mappingDeletedSuccess": "Mapping successfully deleted",
"mappingDeletedError": "Could not delete mapping: '{{error}}'",
"mappingUpdatedSuccess": "Mapping successfully updated",
"mappingUpdatedError": "Could not update mapping: '{{error}}'",
"realmRolePrefix": "Realm role prefix",
"multiValued": "Multivalued",
"tokenClaimName": "Token claim name",
"claimJsonType": "Claim JSON type",
"addClaimTo": "Add claim to",
"idToken": "ID token",
"accessToken": "Access token",
"userInfo": "User info",
"createSuccess": "Client scope created",
"createError": "Could not create client scope: '{{error}}'",
"updateSuccess": "Client scope updated",

View file

@ -16,7 +16,7 @@ export const HelpItem = ({ helpText, forLabel, forID }: HelpItemProps) => {
return (
<>
{enabled && (
<Popover bodyContent={helpText}>
<Popover bodyContent={t(helpText)}>
<button
aria-label={t(`helpLabel`, { label: forLabel })}
onClick={(e) => e.preventDefault()}

View file

@ -18,7 +18,6 @@ import {
} from "@patternfly/react-core";
import { HelpContext } from "../help-enabler/HelpHeader";
import { useTranslation } from "react-i18next";
import { PageBreadCrumbs } from "../bread-crumb/PageBreadCrumbs";
import { ExternalLink } from "../external-link/ExternalLink";
import { isRowExpanded } from "@patternfly/react-table";
@ -68,10 +67,10 @@ export const ViewHeader = ({
</Level>
</LevelItem>
<LevelItem></LevelItem>
{dropdownItems && (
<LevelItem>
<Toolbar>
<ToolbarContent>
{onToggle && (
<ToolbarItem>
<Switch
id={`${titleKey}-switch`}
@ -86,6 +85,8 @@ export const ViewHeader = ({
}}
/>
</ToolbarItem>
)}
{dropdownItems && (
<ToolbarItem>
<Dropdown
position={DropdownPosition.right}
@ -98,10 +99,10 @@ export const ViewHeader = ({
dropdownItems={dropdownItems}
/>
</ToolbarItem>
)}
</ToolbarContent>
</Toolbar>
</LevelItem>
)}
</Level>
{enabled && (
<TextContent>

View file

@ -17,6 +17,7 @@ import { NewRealmForm } from "./realm/add/NewRealmForm";
import { SessionsSection } from "./sessions/SessionsSection";
import { UserFederationSection } from "./user-federation/UserFederationSection";
import { UsersSection } from "./user/UsersSection";
import { MappingDetails } from "./client-scopes/details/MappingDetails";
import { AccessType } from "./context/whoami/who-am-i-model";
@ -67,7 +68,7 @@ export const routes: RoutesFn = (t: TFunction) => [
access: "view-clients",
},
{
path: "/client-scopes/add-client-scopes",
path: "/client-scopes/new",
component: ClientScopeForm,
breadcrumb: t("client-scopes:createClientScope"),
access: "manage-clients",
@ -78,6 +79,18 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("client-scopes:clientScopeDetails"),
access: "view-clients",
},
{
path: "/client-scopes/:scopeId/:id",
component: MappingDetails,
breadcrumb: t("client-scopes:mappingDetails"),
access: "view-clients",
},
{
path: "/client-scopes/:id",
component: ClientScopeForm,
breadcrumb: t("client-scopes:clientScopeDetails"),
access: "view-clients",
},
{
path: "/roles",
component: RealmRolesSection,