Profile view update (#1357)

* added routing for viewing client profile

* added add executors form template

* added add executors form template

* add executor: wip

* add executor: wip

* add executor: wip

* add executor: wip

* add executor: wip

* add executor: wip

* add executor: wip

* add executor: wip

* add executor: wip

* added adding excutors to profiles

* added displaying executors - wip

* added displaying executors - wip

* added navigation to client profile edit on executor creation

* replaced table with list for listing executors added

* added support for editing client profile

* added logic making executors with config links only

* added read only values for edit/view client temporarily

* added helpText for added executors listed in executors list

* added helpText for added executors listed in executors list

* added deleting executor from client profile

* fixed deleting client profile and fixed messages for delete modals

* fixed message for delete clinet profile modal

* combined delete dialogs for client profile and executor

* displaying global executors for global profiles, hiding add executor button

* fixed eslint issue

* fixed executors list

* added back button to global profile view/edit view

* fixed test

* added global batche and hid actions dropdown for global profiles

* fixed switch on/off labels

* fixed hide/display items for global and non-global profiles

* added isDirty

* feedback fixes

* feedback fixes

* feedback fixes

* feedback fixes

* feedback fixes

* feedback fix

* small refactor

* added name and removed unused state

* fixed executor creation

* fixed executor creation

* added saving edited client profile

* added saving edited client profile

* improved trash icon styles

* test fix

* Some code suggestions

* Some more code suggestions

* feedback fixes

* feedback fixes

* use find instead of filter

* feedback fixes

* added defaultValues for executors

* removed defaultValues for executors

* final feedback fixes

* minor fixes

Co-authored-by: Agnieszka Gancarczyk <agancarc@redhat.com>
Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
agagancarczyk 2021-10-26 21:16:19 +01:00 committed by GitHub
parent aad3b2ba43
commit 56eb774dd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1009 additions and 371 deletions

View file

@ -437,6 +437,7 @@ describe("Realm settings tests", () => {
9 9
); );
}); });
});
describe("Realm settings client profiles tab tests", () => { describe("Realm settings client profiles tab tests", () => {
beforeEach(() => { beforeEach(() => {
@ -491,8 +492,9 @@ describe("Realm settings tests", () => {
realmSettingsPage.shouldCompleteAndCreateNewClientProfile(); realmSettingsPage.shouldCompleteAndCreateNewClientProfile();
realmSettingsPage.shouldNotCreateDuplicateClientProfile(); realmSettingsPage.shouldNotCreateDuplicateClientProfile();
}); });
});
describe("Realm settings client policies tab tests", () => { describe.skip("Realm settings client policies tab tests", () => {
beforeEach(() => { beforeEach(() => {
keycloakBefore(); keycloakBefore();
loginPage.logIn(); loginPage.logIn();
@ -562,5 +564,3 @@ describe("Realm settings tests", () => {
}); });
}); });
}); });
});
});

View file

@ -709,11 +709,11 @@ export default class RealmSettingsPage {
cy.get(this.moreDrpDwnItems).click(); cy.get(this.moreDrpDwnItems).click();
cy.get(this.deleteDialogTitle).contains("Delete profile?"); cy.get(this.deleteDialogTitle).contains("Delete profile?");
cy.get(this.deleteDialogBodyText).contains( cy.get(this.deleteDialogBodyText).contains(
"This action will permanently delete the profile custom-profile. This cannot be undone." "This action will permanently delete the profile Test. This cannot be undone."
); );
cy.findByTestId("modalConfirm").contains("Delete"); cy.findByTestId("modalConfirm").contains("Delete");
cy.get(this.deleteDialogCancelBtn).contains("Cancel").click(); cy.get(this.deleteDialogCancelBtn).contains("Cancel").click();
cy.get("table").should("not.have.text", "Test"); cy.get("table").should("be.visible").contains("td", "Test");
} }
shouldDeleteClientPolicyDialog() { shouldDeleteClientPolicyDialog() {

View file

@ -26,6 +26,7 @@ export const BooleanComponent = ({
> >
<Controller <Controller
name={`config.${name?.replaceAll(".", "-")}`} name={`config.${name?.replaceAll(".", "-")}`}
data-testid={name}
defaultValue={defaultValue} defaultValue={defaultValue}
control={control} control={control}
render={({ onChange, value }) => ( render={({ onChange, value }) => (
@ -33,7 +34,7 @@ export const BooleanComponent = ({
id={name!} id={name!}
label={t("common:on")} label={t("common:on")}
labelOff={t("common:off")} labelOff={t("common:off")}
isChecked={value === "true"} isChecked={value === "true" || value === true}
onChange={(value) => onChange("" + value)} onChange={(value) => onChange("" + value)}
/> />
)} )}

View file

@ -32,6 +32,7 @@ export const ListComponent = ({
> >
<Controller <Controller
name={`config.${name?.replaceAll(".", "-")}`} name={`config.${name?.replaceAll(".", "-")}`}
data-testid={name}
defaultValue={defaultValue || ""} defaultValue={defaultValue || ""}
control={control} control={control}
render={({ onChange, value }) => ( render={({ onChange, value }) => (

View file

@ -0,0 +1,75 @@
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 { HelpItem } from "../../../components/help-enabler/HelpItem";
import type { ComponentProps } from "./components";
export const MultivaluedListComponent = ({
name,
label,
helpText,
defaultValue,
options,
}: ComponentProps) => {
const { t } = useTranslation("client-scopes");
const { control } = useFormContext();
const [open, setOpen] = useState(false);
return (
<FormGroup
label={t(label!)}
labelIcon={
<HelpItem helpText={t(helpText!)} forLabel={t(label!)} forID={name!} />
}
fieldId={name!}
>
<Controller
name={label!}
defaultValue={defaultValue || []}
control={control}
render={({ onChange, value }) => (
<Select
toggleId={name}
data-testid={name}
chipGroupProps={{
numChips: 1,
expandedText: t("common:hide"),
collapsedText: t("common:showRemaining"),
}}
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel={t("common:select")}
onToggle={(isOpen) => setOpen(isOpen)}
selections={value}
onSelect={(_, v) => {
const option = v.toString();
if (!value) {
onChange([option]);
} else if (value.includes(option)) {
onChange(value.filter((item: string) => item !== option));
} else {
onChange([...value, option]);
}
}}
onClear={(event) => {
event.stopPropagation();
onChange([]);
}}
isOpen={open}
aria-label={t(label!)}
>
{options?.map((option) => (
<SelectOption key={option} value={option} />
))}
</Select>
)}
/>
</FormGroup>
);
};

View file

@ -142,6 +142,7 @@ export const RoleComponent = ({ name, label, helpText }: ComponentProps) => {
{clients && ( {clients && (
<Select <Select
toggleId={name!} toggleId={name!}
data-testid={name}
onToggle={() => setClientsOpen(!clientsOpen)} onToggle={() => setClientsOpen(!clientsOpen)}
isOpen={clientsOpen} isOpen={clientsOpen}
variant={SelectVariant.typeahead} variant={SelectVariant.typeahead}

View file

@ -35,6 +35,7 @@ export const ScriptComponent = ({
render={({ onChange, value }) => ( render={({ onChange, value }) => (
<CodeEditor <CodeEditor
id={name!} id={name!}
data-testid={name}
type="text" type="text"
onChange={onChange} onChange={onChange}
code={value} code={value}

View file

@ -25,6 +25,7 @@ export const StringComponent = ({
> >
<TextInput <TextInput
id={name!} id={name!}
data-testid={name}
ref={register()} ref={register()}
type="text" type="text"
name={`config.${name?.replaceAll(".", "-")}`} name={`config.${name?.replaceAll(".", "-")}`}

View file

@ -6,6 +6,7 @@ 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 { MultivaluedListComponent } from "./MultivaluedListComponent";
import { ClientSelectComponent } from "./ClientSelectComponent"; import { ClientSelectComponent } from "./ClientSelectComponent";
export type ComponentProps = Omit<ConfigPropertyRepresentation, "type">; export type ComponentProps = Omit<ConfigPropertyRepresentation, "type">;
@ -15,6 +16,7 @@ const ComponentTypes = [
"List", "List",
"Role", "Role",
"Script", "Script",
"MultivaluedList",
"ClientList", "ClientList",
] as const; ] as const;
@ -28,5 +30,9 @@ export const COMPONENTS: {
List: ListComponent, List: ListComponent,
Role: RoleComponent, Role: RoleComponent,
Script: ScriptComponent, Script: ScriptComponent,
MultivaluedList: MultivaluedListComponent,
ClientList: ClientSelectComponent, ClientList: ClientSelectComponent,
} as const; } as const;
export const isValidComponentType = (value: string): value is Components =>
value in COMPONENTS;

View file

@ -25,8 +25,8 @@ 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 { COMPONENTS, isValidComponentType } from "../add/components/components";
import { MapperParams, MapperRoute } from "../routes/Mapper"; import { MapperParams, MapperRoute } from "../routes/Mapper";
import { Components, COMPONENTS } from "../add/components/components";
import { toClientScope } from "../routes/ClientScope"; import { toClientScope } from "../routes/ClientScope";
import "./mapping-details.css"; import "./mapping-details.css";
@ -178,9 +178,6 @@ export const MappingDetails = () => {
} }
}; };
const isValidComponentType = (value: string): value is Components =>
value in COMPONENTS;
return ( return (
<> <>
<DeleteConfirm /> <DeleteConfirm />

View file

@ -34,6 +34,7 @@ export default {
disabled: "Disabled", disabled: "Disabled",
disable: "Disable", disable: "Disable",
selectOne: "Select an option", selectOne: "Select an option",
select: "Select",
choose: "Choose...", choose: "Choose...",
any: "Any", any: "Any",
none: "None", none: "None",
@ -48,6 +49,9 @@ export default {
documentation: "Documentation", documentation: "Documentation",
enableHelpMode: "Enable help mode", enableHelpMode: "Enable help mode",
learnMore: "Learn more", learnMore: "Learn more",
show: "Show",
hide: "Hide",
showRemaining: "Show ${remaining}",
test: "Test", test: "Test",
testConnection: "Test connection", testConnection: "Test connection",
name: "Name", name: "Name",

View file

@ -271,8 +271,8 @@ export const AdminEvents = () => {
data-testid="resource-types-searchField" data-testid="resource-types-searchField"
chipGroupProps={{ chipGroupProps={{
numChips: 1, numChips: 1,
expandedText: "Hide", expandedText: t("common:hide"),
collapsedText: "Show ${remaining}", collapsedText: t("common:showRemaining"),
}} }}
variant={SelectVariant.typeaheadMulti} variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select" typeAheadAriaLabel="Select"
@ -336,8 +336,8 @@ export const AdminEvents = () => {
data-testid="operation-types-searchField" data-testid="operation-types-searchField"
chipGroupProps={{ chipGroupProps={{
numChips: 1, numChips: 1,
expandedText: "Hide", expandedText: t("common:hide"),
collapsedText: "Show ${remaining}", collapsedText: t("common:showRemaining"),
}} }}
variant={SelectVariant.typeaheadMulti} variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select" typeAheadAriaLabel="Select"

View file

@ -252,8 +252,8 @@ export const EventsSection = () => {
data-testid="event-type-searchField" data-testid="event-type-searchField"
chipGroupProps={{ chipGroupProps={{
numChips: 1, numChips: 1,
expandedText: "Hide", expandedText: t("common:hide"),
collapsedText: "Show ${remaining}", collapsedText: t("common:showRemaining"),
}} }}
variant={SelectVariant.typeaheadMulti} variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select" typeAheadAriaLabel="Select"

View file

@ -0,0 +1,482 @@
import React, { useEffect, useMemo, useState } from "react";
import {
ActionGroup,
AlertVariant,
Button,
ButtonVariant,
DataList,
DataListCell,
DataListItem,
DataListItemCells,
DataListItemRow,
Divider,
DropdownItem,
Flex,
FlexItem,
FormGroup,
PageSection,
Text,
TextArea,
TextInput,
TextVariants,
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { FormAccess } from "../components/form-access/FormAccess";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { Link, useHistory, useParams } from "react-router-dom";
import { useAlerts } from "../components/alert/Alerts";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import type ClientProfileRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfileRepresentation";
import { HelpItem } from "../components/help-enabler/HelpItem";
import { PlusCircleIcon, TrashIcon } from "@patternfly/react-icons";
import "./RealmSettingsSection.css";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { toAddExecutor } from "./routes/AddExecutor";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import type { ClientProfileParams } from "./routes/ClientProfile";
type ClientProfileForm = Required<ClientProfileRepresentation>;
const defaultValues: ClientProfileForm = {
name: "",
description: "",
executors: [],
};
export const ClientProfileForm = () => {
const { t } = useTranslation("realm-settings");
const history = useHistory();
const {
handleSubmit,
setValue,
register,
errors,
formState: { isDirty },
} = useForm<ClientProfileForm>({
defaultValues,
mode: "onChange",
});
const { addAlert, addError } = useAlerts();
const adminClient = useAdminClient();
const [globalProfiles, setGlobalProfiles] = useState<
ClientProfileRepresentation[]
>([]);
const [profiles, setProfiles] = useState<ClientProfileRepresentation[]>([]);
const { realm, profileName } = useParams<ClientProfileParams>();
const serverInfo = useServerInfo();
const executorTypes = useMemo(
() =>
serverInfo.componentTypes?.[
"org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider"
],
[]
);
const [executorToDelete, setExecutorToDelete] =
useState<{ idx: number; name: string }>();
const editMode = profileName ? true : false;
const [key, setKey] = useState(0);
const reload = () => setKey(new Date().getTime());
useFetch(
() =>
adminClient.clientPolicies.listProfiles({ includeGlobalProfiles: true }),
(profiles) => {
setGlobalProfiles(profiles.globalProfiles ?? []);
setProfiles(profiles.profiles ?? []);
},
[key]
);
const save = async (form: ClientProfileForm) => {
const updatedProfiles = editMode ? patchProfiles(form) : addProfile(form);
try {
await adminClient.clientPolicies.createProfiles({
profiles: updatedProfiles,
globalProfiles: globalProfiles,
});
addAlert(
editMode
? t("realm-settings:updateClientProfileSuccess")
: t("realm-settings:createClientProfileSuccess"),
AlertVariant.success
);
history.push(`/${realm}/realm-settings/clientPolicies/${form.name}`);
} catch (error) {
addError(
editMode
? "realm-settings:updateClientProfileError"
: "realm-settings:createClientProfileError",
error
);
}
};
const patchProfiles = (data: ClientProfileRepresentation) =>
profiles.map((profile) => {
if (profile.name !== profileName) {
return profile;
}
return {
...profile,
name: data.name,
description: data.description,
};
});
const addProfile = (data: ClientProfileRepresentation) =>
profiles.concat({
...data,
executors: [],
});
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: executorToDelete?.name!
? t("deleteExecutorProfileConfirmTitle")
: t("deleteClientProfileConfirmTitle"),
messageKey: executorToDelete?.name!
? t("deleteExecutorProfileConfirm", {
executorName: executorToDelete.name!,
})
: t("deleteClientProfileConfirm", {
profileName,
}),
continueButtonLabel: t("delete"),
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
if (executorToDelete?.name!) {
profileExecutors.splice(executorToDelete.idx!, 1);
try {
await adminClient.clientPolicies.createProfiles({
profiles: profiles,
globalProfiles,
});
addAlert(t("deleteExecutorSuccess"), AlertVariant.success);
history.push(
`/${realm}/realm-settings/clientPolicies/${profileName}`
);
} catch (error) {
addError(t("deleteExecutorError"), error);
}
} else {
const updatedProfiles = profiles.filter(
(profile) => profile.name !== profileName
);
try {
await adminClient.clientPolicies.createProfiles({
profiles: updatedProfiles,
globalProfiles,
});
addAlert(t("deleteClientSuccess"), AlertVariant.success);
history.push(`/${realm}/realm-settings/clientPolicies`);
} catch (error) {
addError(t("deleteClientError"), error);
}
}
},
});
const profile = profiles.find((profile) => profile.name === profileName);
const profileExecutors = profile?.executors || [];
const globalProfile = globalProfiles.find(
(globalProfile) => globalProfile.name === profileName
);
const globalProfileExecutors = globalProfile?.executors || [];
useEffect(() => {
setValue("name", globalProfile?.name ?? profile?.name);
setValue("description", globalProfile?.description ?? profile?.description);
}, [profiles]);
return (
<>
<DeleteConfirm />
<ViewHeader
titleKey={editMode ? profileName : t("newClientProfile")}
badges={[
{
id: "global-client-profile-badge",
text: globalProfile ? t("global") : "",
},
]}
divider
dropdownItems={
!globalProfile
? [
<DropdownItem
key="delete"
value="delete"
onClick={toggleDeleteDialog}
data-testid="deleteClientProfileDropdown"
>
{t("deleteClientProfile")}
</DropdownItem>,
]
: undefined
}
/>
<PageSection variant="light">
<FormAccess isHorizontal role="view-realm" className="pf-u-mt-lg">
<FormGroup
label={t("newClientProfileName")}
fieldId="kc-name"
helperText={t("createClientProfileNameHelperText")}
isRequired
helperTextInvalid={t("common:required")}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
>
<TextInput
ref={register({ required: true })}
name="name"
type="text"
id="name"
aria-label={t("name")}
data-testid="client-profile-name"
isReadOnly={!!globalProfile}
/>
</FormGroup>
<FormGroup label={t("common:description")} fieldId="kc-description">
<TextArea
ref={register()}
name="description"
type="text"
id="description"
aria-label={t("description")}
data-testid="client-profile-description"
isReadOnly={!!globalProfile}
/>
</FormGroup>
<ActionGroup>
{!globalProfile && (
<Button
variant="primary"
onClick={() => handleSubmit(save)()}
data-testid="saveCreateProfile"
isDisabled={!isDirty}
>
{t("common:save")}
</Button>
)}
{editMode && !globalProfile && (
<Button
id={"reloadProfile"}
variant="link"
data-testid={"reloadProfile"}
isDisabled={!isDirty}
onClick={reload}
>
{t("realm-settings:reload")}
</Button>
)}
{!editMode && !globalProfile && (
<Button
id={"cancelCreateProfile"}
component={(props) => (
<Link
{...props}
to={`/${realm}/realm-settings/clientPolicies`}
/>
)}
data-testid={"cancelCreateProfile"}
>
{t("common:cancel")}
</Button>
)}
</ActionGroup>
{editMode && (
<>
<Flex>
<FlexItem>
<Text className="kc-executors" component={TextVariants.h1}>
{t("executors")}
<HelpItem
helpText={t("realm-settings:executorsHelpText")}
forLabel={t("executorsHelpItem")}
forID={t("executors")}
/>
</Text>
</FlexItem>
{profile && (
<FlexItem align={{ default: "alignRight" }}>
<Button
id="addExecutor"
component={(props) => (
<Link
{...props}
to={toAddExecutor({
realm,
profileName,
})}
></Link>
)}
variant="link"
className="kc-addExecutor"
data-testid="cancelCreateProfile"
icon={<PlusCircleIcon />}
>
{t("realm-settings:addExecutor")}
</Button>
</FlexItem>
)}
</Flex>
{profileExecutors.length > 0 && (
<DataList aria-label={t("executors")} isCompact>
{profileExecutors.map((executor, idx) => (
<DataListItem
aria-labelledby={"executors-list-item"}
key={executor.executor}
id={executor.executor}
>
<DataListItemRow data-testid="executors-list-row">
<DataListItemCells
dataListCells={[
<DataListCell
key="executor"
data-testid="executor-type"
>
{Object.keys(executor).length !== 0 ? (
<Link
data-testid="executor-type-link"
to={""}
className="kc-executor-link"
>
{executor.executor}
</Link>
) : (
executor.executor
)}
{executorTypes
?.filter(
(type) => type.id === executor.executor
)
.map((type) => (
<>
<HelpItem
key={type.id}
helpText={type.helpText}
forLabel={t("executorTypeTextHelpText")}
forID={t(`common:helpLabel`, {
label: t("executorTypeTextHelpText"),
})}
/>
<Button
variant="link"
isInline
icon={
<TrashIcon
key={`executorType-trash-icon-${type.id}`}
className="kc-executor-trash-icon"
data-testid="deleteExecutor"
/>
}
onClick={() => {
toggleDeleteDialog();
setExecutorToDelete({
idx: idx,
name: type.id,
});
}}
></Button>
</>
))}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
))}
</DataList>
)}
{globalProfileExecutors.length > 0 && (
<>
<DataList aria-label={t("executors")} isCompact>
{globalProfileExecutors.map((executor) => (
<DataListItem
aria-labelledby={"global-executors-list-item"}
key={executor.executor}
id={executor.executor}
>
<DataListItemRow data-testid="global-executors-list-row">
<DataListItemCells
dataListCells={[
<DataListCell
key="executor"
data-testid="global-executor-type"
>
{Object.keys(executor).length !== 0 ? (
<Link
data-testid="global-executor-type-link"
to={""}
className="kc-global-executor-link"
>
{executor.executor}
</Link>
) : (
executor.executor
)}
{executorTypes
?.filter(
(type) => type.id === executor.executor
)
.map((type) => (
<HelpItem
key={type.id}
helpText={type.helpText}
forLabel={t("executorTypeTextHelpText")}
forID={t(`common:helpLabel`, {
label: t("executorTypeTextHelpText"),
})}
/>
))}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
))}
</DataList>
<Button
id="backToClientPolicies"
component={(props) => (
<Link
{...props}
to={`/${realm}/realm-settings/clientPolicies`}
/>
)}
variant="primary"
className="kc-backToPolicies"
data-testid="backToClientPolicies"
>
{t("realm-settings:back")}
</Button>
</>
)}
{profileExecutors.length === 0 &&
globalProfileExecutors.length === 0 && (
<>
<Divider />
<Text
className="kc-emptyExecutors"
component={TextVariants.h6}
>
{t("realm-settings:emptyExecutors")}
</Text>
</>
)}
</>
)}
</FormAccess>
</PageSection>
</>
);
};

View file

@ -0,0 +1,207 @@
import React, { useState } from "react";
import {
ActionGroup,
AlertVariant,
Button,
FormGroup,
PageSection,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { FormAccess } from "../components/form-access/FormAccess";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAlerts } from "../components/alert/Alerts";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { HelpItem } from "../components/help-enabler/HelpItem";
import { Link, useHistory, useParams } from "react-router-dom";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import type ComponentTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentTypeRepresentation";
import type { ConfigPropertyRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/authenticatorConfigInfoRepresentation";
import type ClientProfileRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfileRepresentation";
import type { ClientProfileParams } from "./routes/ClientProfile";
import {
COMPONENTS,
isValidComponentType,
} from "../client-scopes/add/components/components";
import type ClientPolicyExecutorRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientPolicyExecutorRepresentation";
type ExecutorForm = Required<ClientPolicyExecutorRepresentation>;
const defaultValues: ExecutorForm = {
configuration: {},
executor: "",
};
export const ExecutorForm = () => {
const { t } = useTranslation("realm-settings");
const history = useHistory();
const { realm, profileName } = useParams<ClientProfileParams>();
const { addAlert, addError } = useAlerts();
const [selectExecutorTypeOpen, setSelectExecutorTypeOpen] = useState(false);
const serverInfo = useServerInfo();
const adminClient = useAdminClient();
const executorTypes =
serverInfo.componentTypes?.[
"org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider"
];
const [executors, setExecutors] = useState<ComponentTypeRepresentation[]>([]);
const [executorProperties, setExecutorProperties] = useState<
ConfigPropertyRepresentation[]
>([]);
const [globalProfiles, setGlobalProfiles] = useState<
ClientProfileRepresentation[]
>([]);
const [profiles, setProfiles] = useState<ClientProfileRepresentation[]>([]);
const form = useForm<ExecutorForm>({ defaultValues });
const { control } = form;
useFetch(
() =>
adminClient.clientPolicies.listProfiles({ includeGlobalProfiles: true }),
(profiles) => {
setGlobalProfiles(profiles.globalProfiles ?? []);
setProfiles(profiles.profiles ?? []);
},
[]
);
const fldNameFormatter = (name: string) =>
name.toLowerCase().trim().split(/\s+/).join("-");
const save = async () => {
const formValues = form.getValues();
const updatedProfiles = profiles.map((profile) => {
if (profile.name !== profileName) {
return profile;
}
const executors = (profile.executors ?? []).concat({
executor: formValues.executor,
configuration: formValues.configuration,
});
return {
...profile,
executors,
};
});
try {
await adminClient.clientPolicies.createProfiles({
profiles: updatedProfiles,
globalProfiles: globalProfiles,
});
addAlert(t("realm-settings:addExecutorSuccess"), AlertVariant.success);
history.push(`/${realm}/realm-settings/clientPolicies/${profileName}`);
} catch (error) {
addError("realm-settings:addExecutorError", error);
}
};
return (
<>
<ViewHeader titleKey={t("addExecutor")} divider />
<PageSection variant="light">
<FormAccess isHorizontal role="manage-realm" className="pf-u-mt-lg">
<FormGroup
label={t("executorType")}
fieldId="kc-executorType"
labelIcon={
executors.length > 0 && executors[0].helpText! !== "" ? (
<HelpItem
helpText={executors[0].helpText}
forLabel={t("executorTypeHelpText")}
forID={t(`common:helpLabel`, {
label: t("executorTypeHelpText"),
})}
/>
) : undefined
}
>
<Controller
name="executor"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-executor"
placeholderText="Select an executor"
onToggle={(isOpen) => setSelectExecutorTypeOpen(isOpen)}
onSelect={(_, value) => {
onChange(value.toString());
const selectedExecutor = executorTypes?.filter(
(type) => type.id === value
);
setExecutors(selectedExecutor ?? []);
setExecutorProperties(
selectedExecutor?.[0].properties ?? []
);
setSelectExecutorTypeOpen(false);
}}
selections={value}
variant={SelectVariant.single}
data-testid="executorType-select"
aria-label={t("executorType")}
isOpen={selectExecutorTypeOpen}
maxHeight={580}
>
{executorTypes?.map((option) => (
<SelectOption
selected={option.id === value}
key={option.id}
value={option.id}
description={option.helpText}
/>
))}
</Select>
)}
/>
</FormGroup>
<FormProvider {...form}>
{executorProperties.map((option) => {
const componentType = option.type!;
if (isValidComponentType(componentType)) {
const Component = COMPONENTS[componentType];
return (
<Component
key={option.name}
{...option}
name={fldNameFormatter(option.label!)}
label={option.label}
/>
);
} else {
console.warn(
`There is no editor registered for ${componentType}`
);
}
})}
</FormProvider>
<ActionGroup>
<Button
variant="primary"
onClick={save}
data-testid="realm-settings-add-executor-save-button"
>
{t("common:add")}
</Button>
<Button
variant="link"
component={(props) => (
<Link
{...props}
to={`/${realm}/realm-settings/clientPolicies/${profileName}`}
/>
)}
data-testid="realm-settings-add-executor-cancel-button"
>
{t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
</PageSection>
</>
);
};

View file

@ -1,234 +0,0 @@
import React, { useState } from "react";
import {
ActionGroup,
AlertVariant,
Button,
ButtonVariant,
Divider,
DropdownItem,
Flex,
FlexItem,
FormGroup,
PageSection,
Text,
TextArea,
TextInput,
TextVariants,
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { FormAccess } from "../components/form-access/FormAccess";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { Link, useHistory } from "react-router-dom";
import { useRealm } from "../context/realm-context/RealmContext";
import { useAlerts } from "../components/alert/Alerts";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import type ClientProfileRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfileRepresentation";
import { HelpItem } from "../components/help-enabler/HelpItem";
import { PlusCircleIcon } from "@patternfly/react-icons";
import "./RealmSettingsSection.css";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
type NewClientProfileForm = Required<ClientProfileRepresentation>;
const defaultValues: NewClientProfileForm = {
name: "",
description: "",
executors: [],
};
export const NewClientProfileForm = () => {
const { t } = useTranslation("realm-settings");
const { getValues, register, errors } = useForm<NewClientProfileForm>({
defaultValues,
});
const { realm } = useRealm();
const { addAlert, addError } = useAlerts();
const adminClient = useAdminClient();
const [globalProfiles, setGlobalProfiles] = useState<
ClientProfileRepresentation[]
>([]);
const [profiles, setProfiles] = useState<ClientProfileRepresentation[]>([]);
const [showAddExecutorsForm, setShowAddExecutorsForm] = useState(false);
const [createdProfile, setCreatedProfile] =
useState<ClientProfileRepresentation>();
const form = getValues();
const history = useHistory();
useFetch(
() =>
adminClient.clientPolicies.listProfiles({ includeGlobalProfiles: true }),
(profiles) => {
setGlobalProfiles(profiles.globalProfiles ?? []);
setProfiles(profiles.profiles ?? []);
},
[]
);
const save = async () => {
const form = getValues();
const createdProfile = {
...form,
executors: [],
};
const allProfiles = profiles.concat(createdProfile);
try {
await adminClient.clientPolicies.createProfiles({
profiles: allProfiles,
globalProfiles: globalProfiles,
});
addAlert(
t("realm-settings:createClientProfileSuccess"),
AlertVariant.success
);
setShowAddExecutorsForm(true);
setCreatedProfile(createdProfile);
} catch (error) {
addError("realm-settings:createClientProfileError", error);
}
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("deleteClientProfileConfirmTitle"),
messageKey: t("deleteClientProfileConfirm"),
continueButtonLabel: t("delete"),
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
const updatedProfiles = profiles.filter(
(profile) => profile.name !== createdProfile?.name
);
try {
await adminClient.clientPolicies.createProfiles({
profiles: updatedProfiles,
globalProfiles,
});
addAlert(t("deleteClientSuccess"), AlertVariant.success);
history.push(`/${realm}/realm-settings/clientPolicies`);
} catch (error) {
addError(t("deleteClientError"), error);
}
},
});
return (
<>
<DeleteConfirm />
<ViewHeader
titleKey={showAddExecutorsForm ? form.name : t("newClientProfile")}
divider
dropdownItems={
showAddExecutorsForm
? [
<DropdownItem
key="delete"
value="delete"
onClick={toggleDeleteDialog}
data-testid="deleteClientProfileDropdown"
>
{t("deleteClientProfile")}
</DropdownItem>,
]
: undefined
}
/>
<PageSection variant="light">
<FormAccess isHorizontal role="view-realm" className="pf-u-mt-lg">
<FormGroup
label={t("newClientProfileName")}
fieldId="kc-name"
helperText={t("createClientProfileNameHelperText")}
isRequired
helperTextInvalid={t("common:required")}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
>
<TextInput
ref={register({ required: true })}
type="text"
id="kc-client-profile-name"
name="name"
data-testid="client-profile-name"
/>
</FormGroup>
<FormGroup label={t("common:description")} fieldId="kc-description">
<TextArea
name="description"
aria-label={t("description")}
ref={register()}
type="text"
id="kc-client-profile-description"
data-testid="client-profile-description"
/>
</FormGroup>
<ActionGroup>
<Button
variant="primary"
onClick={save}
data-testid="saveCreateProfile"
isDisabled={showAddExecutorsForm ? true : false}
>
{t("common:save")}
</Button>
<Button
id="cancelCreateProfile"
component={(props) => (
<Link
{...props}
to={`/${realm}/realm-settings/clientPolicies`}
/>
)}
data-testid="cancelCreateProfile"
>
{showAddExecutorsForm
? t("realm-settings:reload")
: t("common:cancel")}
</Button>
</ActionGroup>
{showAddExecutorsForm && (
<>
<Flex>
<FlexItem>
<Text className="kc-executors" component={TextVariants.h1}>
{t("executors")}
<HelpItem
helpText={t("realm-settings:executorsHelpText")}
forLabel={t("executorsHelpItem")}
forID={t("executors")}
/>
</Text>
</FlexItem>
<FlexItem align={{ default: "alignRight" }}>
<Button
id="addExecutor"
component={(props) => (
<Link
{...props}
to={`/${realm}/realm-settings/clientPolicies`}
></Link>
)}
variant="link"
className="kc-addExecutor"
data-testid="cancelCreateProfile"
icon={<PlusCircleIcon />}
>
{t("realm-settings:addExecutor")}
</Button>
</FlexItem>
</Flex>
<Divider />
<Text className="kc-emptyExecutors" component={TextVariants.h6}>
{t("realm-settings:emptyExecutors")}
</Text>
</>
)}
</FormAccess>
</PageSection>
</>
);
};

View file

@ -22,10 +22,10 @@ import { useRealm } from "../context/realm-context/RealmContext";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { prettyPrintJSON } from "../util"; import { prettyPrintJSON } from "../util";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toNewClientProfile } from "./routes/NewClientProfile"; import { toAddClientProfile } from "./routes/AddClientProfile";
import type ClientProfileRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfileRepresentation"; import type ClientProfileRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfileRepresentation";
import "./RealmSettingsSection.css"; import "./RealmSettingsSection.css";
import { toClientProfile } from "./routes/ClientProfile";
type ClientProfile = ClientProfileRepresentation & { type ClientProfile = ClientProfileRepresentation & {
global: boolean; global: boolean;
@ -79,7 +79,9 @@ export const ProfilesTab = () => {
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("deleteClientProfileConfirmTitle"), titleKey: t("deleteClientProfileConfirmTitle"),
messageKey: t("deleteClientProfileConfirm"), messageKey: t("deleteClientProfileConfirm", {
profileName: selectedProfile?.name,
}),
continueButtonLabel: t("delete"), continueButtonLabel: t("delete"),
continueButtonVariant: ButtonVariant.danger, continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => { onConfirm: async () => {
@ -105,7 +107,13 @@ export const ProfilesTab = () => {
}); });
const cellFormatter = (row: ClientProfile) => ( const cellFormatter = (row: ClientProfile) => (
<Link to={""} key={row.name}> <Link
to={toClientProfile({
realm,
profileName: row.name!,
})}
key={row.name}
>
{row.name} {row.global && <Label color="blue">{t("global")}</Label>} {row.name} {row.global && <Label color="blue">{t("global")}</Label>}
</Link> </Link>
); );
@ -197,7 +205,7 @@ export const ProfilesTab = () => {
<Button <Button
id="createProfile" id="createProfile"
component={(props) => ( component={(props) => (
<Link {...props} to={toNewClientProfile({ realm })} /> <Link {...props} to={toAddClientProfile({ realm })} />
)} )}
data-testid="createProfile" data-testid="createProfile"
> >

View file

@ -210,6 +210,23 @@ article.pf-c-card.pf-m-flat.kc-login-settings-template
background-color: transparent; background-color: transparent;
} }
.kc-executor-link {
margin-right: 0.625rem;
}
.kc-backToPolicies {
width: 5rem;
}
.kc-executor-trash-icon {
margin-left: .5rem;
color: var(--pf-global--Color--200);
}
.kc-executor-trash-icon:hover {
filter: brightness(55%);
}
.kc-condition-link { .kc-condition-link {
margin-right: 0.625rem; margin-right: 0.625rem;
} }

View file

@ -238,18 +238,22 @@ export default {
"There are no profiles, select 'Create client profile' to create a new client profile", "There are no profiles, select 'Create client profile' to create a new client profile",
deleteClientProfileConfirmTitle: "Delete profile?", deleteClientProfileConfirmTitle: "Delete profile?",
deleteClientProfileConfirm: deleteClientProfileConfirm:
"This action will permanently delete the profile custom-profile. This cannot be undone.", "This action will permanently delete the profile {{profileName}}. This cannot be undone.",
deleteClientSuccess: "Client profile deleted", deleteClientSuccess: "Client profile deleted",
deleteClientError: "Could not delete profile: {{error}}", deleteClientError: "Could not delete profile: {{error}}",
createClientProfile: "Create client profile", createClientProfile: "Create client profile",
deleteClientProfile: "Delete this client profile", deleteClientProfile: "Delete this client profile",
createClientProfileSuccess: "New client profile created", createClientProfileSuccess: "New client profile created",
updateClientProfileSuccess: "Client profile updated successfully",
createClientProfileError: "Could not create client profile: '{{error}}'", createClientProfileError: "Could not create client profile: '{{error}}'",
updateClientProfileError: "Could not update client profile: '{{error}}'",
createClientProfileNameHelperText: createClientProfileNameHelperText:
"The name must be unique within the realm", "The name must be unique within the realm",
allClientPolicies: "Client policies", allClientPolicies: "Client policies",
newClientProfile: "Create client profile", newClientProfile: "Create client profile",
newClientProfileName: "Client profile name", newClientProfileName: "Client profile name",
clientProfile: "Client profile details",
back: "Back",
delete: "delete", delete: "delete",
save: "Save", save: "Save",
reload: "Reload", reload: "Reload",
@ -260,7 +264,24 @@ export default {
"Executors, which will be applied for this client profile", "Executors, which will be applied for this client profile",
executorsHelpItem: "Executors help item", executorsHelpItem: "Executors help item",
addExecutor: "Add executor", addExecutor: "Add executor",
executorType: "Executor type",
executorTypeSwitchHelpText: "Executor Type Switch Help Text",
executorTypeSelectHelpText: "Executor Type Select Help Text",
executorTypeSelectAlgorithm: "Executor Type Select Algorithm",
executorTypeTextHelpText: "Executor Type Text Help Text",
executorAuthenticatorMultiSelectHelpText:
"Executor Authenticator MultiSelect Help Text",
executorClientAuthenticator: "Executor Client Authenticator",
executorsTable: "Executors table",
executorName: "Name",
emptyExecutors: "No executors configured", emptyExecutors: "No executors configured",
addExecutorSuccess: "Success! Executor created successfully",
addExecutorError: "Executor not created",
deleteExecutorProfileConfirmTitle: "Delete executor?",
deleteExecutorProfileConfirm:
"The action will permanently delete {{executorName}}. This cannot be undone.",
deleteExecutorSuccess: "Success! The executor was deleted.",
deleteExecutorError: "Could not delete executor: {{error}}",
updateClientProfilesSuccess: updateClientProfilesSuccess:
"The client profiles configuration was updated", "The client profiles configuration was updated",
updateClientProfilesError: updateClientProfilesError:

View file

@ -6,7 +6,10 @@ import { JavaKeystoreSettingsRoute } from "./routes/JavaKeystoreSettings";
import { RealmSettingsRoute } from "./routes/RealmSettings"; import { RealmSettingsRoute } from "./routes/RealmSettings";
import { RsaGeneratedSettingsRoute } from "./routes/RsaGeneratedSettings"; import { RsaGeneratedSettingsRoute } from "./routes/RsaGeneratedSettings";
import { RsaSettingsRoute } from "./routes/RsaSettings"; import { RsaSettingsRoute } from "./routes/RsaSettings";
import { NewClientProfileRoute } from "./routes/NewClientProfile"; import { ClientPoliciesRoute } from "./routes/ClientPolicies";
import { AddClientProfileRoute } from "./routes/AddClientProfile";
import { ClientProfileRoute } from "./routes/ClientProfile";
import { AddExecutorRoute } from "./routes/AddExecutor";
import { NewClientPolicyRoute } from "./routes/NewClientPolicy"; import { NewClientPolicyRoute } from "./routes/NewClientPolicy";
import { EditClientPolicyRoute } from "./routes/EditClientPolicy"; import { EditClientPolicyRoute } from "./routes/EditClientPolicy";
import { NewClientPolicyConditionRoute } from "./routes/AddCondition"; import { NewClientPolicyConditionRoute } from "./routes/AddCondition";
@ -19,7 +22,10 @@ const routes: RouteDef[] = [
JavaKeystoreSettingsRoute, JavaKeystoreSettingsRoute,
RsaGeneratedSettingsRoute, RsaGeneratedSettingsRoute,
RsaSettingsRoute, RsaSettingsRoute,
NewClientProfileRoute, ClientPoliciesRoute,
AddClientProfileRoute,
AddExecutorRoute,
ClientProfileRoute,
NewClientPolicyRoute, NewClientPolicyRoute,
EditClientPolicyRoute, EditClientPolicyRoute,
NewClientPolicyConditionRoute, NewClientPolicyConditionRoute,

View file

@ -0,0 +1,21 @@
import type { LocationDescriptorObject } from "history";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { ClientProfileForm } from "../ClientProfileForm";
export type AddClientProfileParams = {
realm: string;
};
export const AddClientProfileRoute: RouteDef = {
path: "/:realm/realm-settings/clientPolicies/add-profile",
component: ClientProfileForm,
breadcrumb: (t) => t("realm-settings:newClientProfile"),
access: "manage-realm",
};
export const toAddClientProfile = (
params: AddClientProfileParams
): LocationDescriptorObject => ({
pathname: generatePath(AddClientProfileRoute.path, params),
});

View file

@ -0,0 +1,22 @@
import type { LocationDescriptorObject } from "history";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { ExecutorForm } from "../ExecutorForm";
export type AddExecutorParams = {
realm: string;
profileName: string;
};
export const AddExecutorRoute: RouteDef = {
path: "/:realm/realm-settings/clientPolicies/:profileName/add-executor",
component: ExecutorForm,
breadcrumb: (t) => t("realm-settings:addExecutor"),
access: "manage-realm",
};
export const toAddExecutor = (
params: AddExecutorParams
): LocationDescriptorObject => ({
pathname: generatePath(AddExecutorRoute.path, params),
});

View file

@ -0,0 +1,22 @@
import type { LocationDescriptorObject } from "history";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { ClientProfileForm } from "../ClientProfileForm";
export type ClientProfileParams = {
realm: string;
profileName: string;
};
export const ClientProfileRoute: RouteDef = {
path: "/:realm/realm-settings/clientPolicies/:profileName",
component: ClientProfileForm,
breadcrumb: (t) => t("realm-settings:clientProfile"),
access: ["view-realm", "view-users"],
};
export const toClientProfile = (
params: ClientProfileParams
): LocationDescriptorObject => ({
pathname: generatePath(ClientProfileRoute.path, params),
});

View file

@ -1,21 +0,0 @@
import type { LocationDescriptorObject } from "history";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { NewClientProfileForm } from "../NewClientProfileForm";
export type NewClientProfileParams = {
realm: string;
};
export const NewClientProfileRoute: RouteDef = {
path: "/:realm/realm-settings/clientPolicies/new-client-profile",
component: NewClientProfileForm,
breadcrumb: (t) => t("realm-settings:newClientProfile"),
access: "view-realm",
};
export const toNewClientProfile = (
params: NewClientProfileParams
): LocationDescriptorObject => ({
pathname: generatePath(NewClientProfileRoute.path, params),
});