Create custom user federation providers page (#1745)

* Create custom user federation providers page

fixes: #1722

* added breadcrumb
This commit is contained in:
Erik Jan de Wit 2022-01-05 18:06:53 +01:00 committed by GitHub
parent 33a1769c39
commit f1c7e5ecb3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 798 additions and 616 deletions

View file

@ -150,7 +150,7 @@ describe("User Fed Kerberos tests", () => {
});
it("Delete a Kerberos provider using the Settings view's Action menu", () => {
providersPage.deleteCardFromMenu(provider, firstKerberosName);
providersPage.deleteCardFromMenu(firstKerberosName);
modalUtils.checkModalTitle(deleteModalTitle).confirmModal();
masthead.checkNotificationMessage(deletedSuccessMessage);

View file

@ -207,7 +207,7 @@ describe("User Fed LDAP mapper tests", () => {
// *** test cleanup ***
it("Cleanup - delete LDAP provider", () => {
providersPage.deleteCardFromMenu(provider, ldapName);
providersPage.deleteCardFromMenu(ldapName);
modalUtils.checkModalTitle(providerDeleteTitle).confirmModal();
masthead.checkNotificationMessage(providerDeleteSuccess);
});

View file

@ -227,7 +227,7 @@ describe("User Fed LDAP mapper tests", () => {
// *** test cleanup ***
it("Cleanup - delete LDAP provider", () => {
providersPage.deleteCardFromMenu(provider, ldapName);
providersPage.deleteCardFromMenu(ldapName);
modalUtils.checkModalTitle(providerDeleteTitle).confirmModal();
masthead.checkNotificationMessage(providerDeleteSuccess);
});

View file

@ -181,7 +181,7 @@ describe("User Fed LDAP tests", () => {
});
it("Delete an LDAP provider using the Settings view's Action menu", () => {
providersPage.deleteCardFromMenu(provider, firstLdapName);
providersPage.deleteCardFromMenu(firstLdapName);
modalUtils.checkModalTitle(deleteModalTitle).confirmModal();
masthead.checkNotificationMessage(deletedSuccessMessage);
});

View file

@ -105,10 +105,10 @@ export default class ProviderPage {
return this;
}
deleteCardFromMenu(providerType: string, card: string) {
deleteCardFromMenu(card: string) {
this.clickExistingCard(card);
cy.get('[data-testid="action-dropdown"]').click();
cy.get(`[data-testid="delete-${providerType}-cmd"]`).click();
cy.get(`[data-testid="delete-cmd"]`).click();
return this;
}

View file

@ -3,8 +3,6 @@ import {
ActionGroup,
AlertVariant,
Button,
ButtonVariant,
DropdownItem,
Form,
PageSection,
} from "@patternfly/react-core";
@ -14,69 +12,13 @@ import { SettingsCache } from "./shared/SettingsCache";
import { useRealm } from "../context/realm-context/RealmContext";
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import { Controller, useForm } from "react-hook-form";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { FormProvider, useForm } from "react-hook-form";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
import { useTranslation } from "react-i18next";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useHistory, useParams } from "react-router-dom";
type KerberosSettingsHeaderProps = {
onChange: (value: string) => void;
value: string;
save: () => void;
toggleDeleteDialog: () => void;
};
const KerberosSettingsHeader = ({
onChange,
value,
save,
toggleDeleteDialog,
}: KerberosSettingsHeaderProps) => {
const { t } = useTranslation("user-federation");
const { id } = useParams<{ id?: string }>();
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "user-federation:userFedDisableConfirmTitle",
messageKey: "user-federation:userFedDisableConfirm",
continueButtonLabel: "common:disable",
onConfirm: () => {
onChange("false");
save();
},
});
return (
<>
<DisableConfirm />
{!id ? (
<ViewHeader titleKey="Kerberos" />
) : (
<ViewHeader
titleKey="Kerberos"
dropdownItems={[
<DropdownItem
key="delete"
onClick={() => toggleDeleteDialog()}
data-testid="delete-kerberos-cmd"
>
{t("deleteProvider")}
</DropdownItem>,
]}
isEnabled={value === "true"}
onToggle={(value) => {
if (!value) {
toggleDisableDialog();
} else {
onChange(value.toString());
save();
}
}}
/>
)}
</>
);
};
import { Header } from "./shared/Header";
import { toUserFederation } from "./routes/UserFederation";
export default function UserFederationKerberosSettings() {
const { t } = useTranslation("user-federation");
@ -124,38 +66,11 @@ export default function UserFederationKerberosSettings() {
}
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "user-federation:userFedDeleteConfirmTitle",
messageKey: "user-federation:userFedDeleteConfirm",
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.components.del({ id: id! });
addAlert(t("userFedDeletedSuccess"), AlertVariant.success);
history.replace(`/${realm}/user-federation`);
} catch (error: any) {
addAlert("user-federation:userFedDeleteError", error);
}
},
});
return (
<>
<DeleteConfirm />
<Controller
name="config.enabled[0]"
defaultValue={["true"][0]}
control={form.control}
render={({ onChange, value }) => (
<KerberosSettingsHeader
value={value}
onChange={onChange}
save={() => save(form.getValues())}
toggleDeleteDialog={toggleDeleteDialog}
/>
)}
/>
<FormProvider {...form}>
<Header provider="Kerberos" save={() => form.handleSubmit(save)()} />
</FormProvider>
<PageSection variant="light">
<KerberosSettingsRequired form={form} showSectionHeading />
</PageSection>
@ -173,7 +88,7 @@ export default function UserFederationKerberosSettings() {
</Button>
<Button
variant="link"
onClick={() => history.push(`/${realm}/user-federation`)}
onClick={() => history.push(toUserFederation({ realm }))}
data-testid="kerberos-cancel"
>
{t("common:cancel")}

View file

@ -3,9 +3,6 @@ import {
ActionGroup,
AlertVariant,
Button,
ButtonVariant,
DropdownItem,
DropdownSeparator,
Form,
PageSection,
Tab,
@ -23,17 +20,17 @@ import { LdapSettingsSearching } from "./ldap/LdapSettingsSearching";
import { useRealm } from "../context/realm-context/RealmContext";
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import { Controller, useForm } from "react-hook-form";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { FormProvider, useForm, useFormContext } from "react-hook-form";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
import { useTranslation } from "react-i18next";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useHistory, useParams } from "react-router-dom";
import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
import { LdapMapperList } from "./ldap/mappers/LdapMapperList";
import { toUserFederation } from "./routes/UserFederation";
import { ExtendedHeader } from "./shared/ExtendedHeader";
type ldapComponentRepresentation = ComponentRepresentation & {
config?: {
@ -42,145 +39,58 @@ type ldapComponentRepresentation = ComponentRepresentation & {
};
};
type LdapSettingsHeaderProps = {
onChange: (value: string) => void;
value: string;
editMode?: string | string[];
save: () => void;
toggleDeleteDialog: () => void;
toggleRemoveUsersDialog: () => void;
};
const LdapSettingsHeader = ({
onChange,
value,
editMode,
const AddLdapFormContent = ({
save,
toggleDeleteDialog,
toggleRemoveUsersDialog,
}: LdapSettingsHeaderProps) => {
}: {
save: (component: ldapComponentRepresentation) => void;
}) => {
const { t } = useTranslation("user-federation");
const form = useFormContext();
const { id } = useParams<{ id: string }>();
const adminClient = useAdminClient();
const { addAlert, addError } = useAlerts();
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "user-federation:userFedDisableConfirmTitle",
messageKey: "user-federation:userFedDisableConfirm",
continueButtonLabel: "common:disable",
onConfirm: () => {
onChange("false");
save();
},
});
const history = useHistory();
const [toggleUnlinkUsersDialog, UnlinkUsersDialog] = useConfirmDialog({
titleKey: "user-federation:userFedUnlinkUsersConfirmTitle",
messageKey: "user-federation:userFedUnlinkUsersConfirm",
continueButtonLabel: "user-federation:unlinkUsers",
onConfirm: () => unlinkUsers(),
});
const syncChangedUsers = async () => {
try {
if (id) {
const response = await adminClient.userStorageProvider.sync({
id: id,
action: "triggerChangedUsersSync",
});
if (response.ignored) {
addAlert(`${response.status}.`, AlertVariant.warning);
} else {
addAlert(
t("syncUsersSuccess") +
`${response.added} users added, ${response.updated} users updated, ${response.removed} users removed, ${response.failed} users failed.`,
AlertVariant.success
);
}
}
} catch (error) {
addError("user-federation:syncUsersError", error);
}
};
const syncAllUsers = async () => {
try {
if (id) {
const response = await adminClient.userStorageProvider.sync({
id: id,
action: "triggerFullSync",
});
if (response.ignored) {
addAlert(`${response.status}.`, AlertVariant.warning);
} else {
addAlert(
t("syncUsersSuccess") +
`${response.added} users added, ${response.updated} users updated, ${response.removed} users removed, ${response.failed} users failed.`,
AlertVariant.success
);
}
}
} catch (error) {
addError("user-federation:syncUsersError", error);
}
};
const unlinkUsers = async () => {
try {
if (id) {
await adminClient.userStorageProvider.unlinkUsers({ id });
}
addAlert(t("unlinkUsersSuccess"), AlertVariant.success);
} catch (error) {
addError("user-federation:unlinkUsersError", error);
}
};
const { realm } = useRealm();
return (
<>
<DisableConfirm />
<UnlinkUsersDialog />
{!id ? (
<ViewHeader titleKey={t("addOneLdap")} />
) : (
<ViewHeader
titleKey="LDAP"
dropdownItems={[
<DropdownItem key="sync" onClick={syncChangedUsers}>
{t("syncChangedUsers")}
</DropdownItem>,
<DropdownItem key="syncall" onClick={syncAllUsers}>
{t("syncAllUsers")}
</DropdownItem>,
<DropdownItem
key="unlink"
isDisabled={editMode ? !editMode.includes("UNSYNCED") : false}
onClick={toggleUnlinkUsersDialog}
>
{t("unlinkUsers")}
</DropdownItem>,
<DropdownItem key="remove" onClick={toggleRemoveUsersDialog}>
{t("removeImported")}
</DropdownItem>,
<DropdownSeparator key="separator" />,
<DropdownItem
key="delete"
onClick={toggleDeleteDialog}
data-testid="delete-ldap-cmd"
>
{t("deleteProvider")}
</DropdownItem>,
]}
isEnabled={value === "true"}
onToggle={(value) => {
if (!value) {
toggleDisableDialog();
} else {
onChange("" + value);
save();
}
}}
/>
)}
<ScrollForm
sections={[
t("generalOptions"),
t("connectionAndAuthenticationSettings"),
t("ldapSearchingAndUpdatingSettings"),
t("synchronizationSettings"),
t("kerberosIntegration"),
t("cacheSettings"),
t("advancedSettings"),
]}
>
<LdapSettingsGeneral form={form} vendorEdit={!!id} />
<LdapSettingsConnection form={form} edit={!!id} />
<LdapSettingsSearching form={form} />
<LdapSettingsSynchronization form={form} />
<LdapSettingsKerberosIntegration form={form} />
<SettingsCache form={form} />
<LdapSettingsAdvanced form={form} />
</ScrollForm>
<Form onSubmit={form.handleSubmit(save)}>
<ActionGroup className="keycloak__form_actions">
<Button
isDisabled={!form.formState.isDirty}
variant="primary"
type="submit"
data-testid="ldap-save"
>
{t("common:save")}
</Button>
<Button
variant="link"
onClick={() => history.push(toUserFederation({ realm }))}
data-testid="ldap-cancel"
>
{t("common:cancel")}
</Button>
</ActionGroup>
</Form>
</>
);
};
@ -231,34 +141,23 @@ export default function UserFederationLdapSettings() {
);
};
const removeImportedUsers = async () => {
try {
if (id) {
await adminClient.userStorageProvider.removeImportedUsers({ id });
}
addAlert(t("removeImportedUsersSuccess"), AlertVariant.success);
} catch (error) {
addError("user-federation:removeImportedUsersError", error);
}
};
const save = async (component: ldapComponentRepresentation) => {
if (component.config?.periodicChangedUsersSync !== null) {
if (component.config?.periodicChangedUsersSync === false) {
if (component.config?.periodicChangedUsersSync !== undefined) {
if (component.config.periodicChangedUsersSync === false) {
component.config.changedSyncPeriod = ["-1"];
}
delete component.config?.periodicChangedUsersSync;
delete component.config.periodicChangedUsersSync;
}
if (component.config?.periodicFullSync !== null) {
if (component.config?.periodicFullSync === false) {
if (component.config?.periodicFullSync !== undefined) {
if (component.config.periodicFullSync === false) {
component.config.fullSyncPeriod = ["-1"];
}
delete component.config?.periodicFullSync;
delete component.config.periodicFullSync;
}
try {
if (!id) {
await adminClient.components.create(component);
history.push(`/${realm}/user-federation`);
history.push(toUserFederation({ realm }));
} else {
await adminClient.components.update({ id }, component);
}
@ -269,101 +168,15 @@ export default function UserFederationLdapSettings() {
}
};
const [toggleRemoveUsersDialog, RemoveUsersConfirm] = useConfirmDialog({
titleKey: t("removeImportedUsers"),
messageKey: t("removeImportedUsersMessage"),
continueButtonLabel: "common:remove",
onConfirm: async () => {
try {
removeImportedUsers();
addAlert(t("removeImportedUsersSuccess"), AlertVariant.success);
} catch (error) {
addError("user-federation:removeImportedUsersError", error);
}
},
});
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "user-federation:userFedDeleteConfirmTitle",
messageKey: "user-federation:userFedDeleteConfirm",
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.components.del({ id });
addAlert(t("userFedDeletedSuccess"), AlertVariant.success);
history.replace(`/${realm}/user-federation`);
} catch (error) {
addError("user-federation:userFedDeleteError", error);
}
},
});
const addLdapFormContent = () => {
return (
<>
<ScrollForm
sections={[
t("generalOptions"),
t("connectionAndAuthenticationSettings"),
t("ldapSearchingAndUpdatingSettings"),
t("synchronizationSettings"),
t("kerberosIntegration"),
t("cacheSettings"),
t("advancedSettings"),
]}
>
<LdapSettingsGeneral form={form} vendorEdit={!!id} />
<LdapSettingsConnection form={form} edit={!!id} />
<LdapSettingsSearching form={form} />
<LdapSettingsSynchronization form={form} />
<LdapSettingsKerberosIntegration form={form} />
<SettingsCache form={form} />
<LdapSettingsAdvanced form={form} />
</ScrollForm>
<Form onSubmit={form.handleSubmit(save)}>
<ActionGroup className="keycloak__form_actions">
<Button
isDisabled={!form.formState.isDirty}
variant="primary"
type="submit"
data-testid="ldap-save"
>
{t("common:save")}
</Button>
<Button
variant="link"
onClick={() => history.push(`/${realm}/user-federation`)}
data-testid="ldap-cancel"
>
{t("common:cancel")}
</Button>
</ActionGroup>
</Form>
</>
);
};
return (
<>
<DeleteConfirm />
<RemoveUsersConfirm />
<Controller
name="config.enabled[0]"
defaultValue={["true"][0]}
control={form.control}
render={({ onChange, value }) => (
<LdapSettingsHeader
editMode={editMode}
value={value}
save={() => save(form.getValues())}
onChange={onChange}
toggleDeleteDialog={toggleDeleteDialog}
toggleRemoveUsersDialog={toggleRemoveUsersDialog}
/>
)}
<FormProvider {...form}>
<ExtendedHeader
provider="LDAP"
noDivider
editMode={editMode}
save={() => form.handleSubmit(save)()}
/>
<PageSection variant="light" isFilled>
<PageSection variant="light" className="pf-u-p-0">
{id ? (
<KeycloakTabs isBox>
<Tab
@ -371,7 +184,9 @@ export default function UserFederationLdapSettings() {
eventKey="settings"
title={<TabTitleText>{t("common:settings")}</TabTitleText>}
>
{addLdapFormContent()}
<PageSection variant="light">
<AddLdapFormContent save={save} />
</PageSection>
</Tab>
<Tab
id="mappers"
@ -379,13 +194,17 @@ export default function UserFederationLdapSettings() {
title={<TabTitleText>{t("common:mappers")}</TabTitleText>}
data-testid="ldap-mappers-tab"
>
<LdapMapperList />
<PageSection>
<LdapMapperList />
</PageSection>
</Tab>
</KeycloakTabs>
) : (
addLdapFormContent()
<PageSection variant="light">
<AddLdapFormContent save={save} />
</PageSection>
)}
</PageSection>
</>
</FormProvider>
);
}

View file

@ -15,15 +15,19 @@ import {
} from "@patternfly/react-core";
import { DatabaseIcon } from "@patternfly/react-icons";
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import { useHistory } from "react-router-dom";
import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { KeycloakCard } from "../components/keycloak-card/KeycloakCard";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { useRealm } from "../context/realm-context/RealmContext";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { toUpperCase } from "../util";
import { toProvider } from "./routes/NewProvider";
import "./user-federation.css";
import helpUrls from "../help-urls";
@ -37,9 +41,13 @@ export default function UserFederationSection() {
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const { url } = useRouteMatch();
const history = useHistory();
const providers =
useServerInfo().componentTypes?.[
"org.keycloak.storage.UserStorageProvider"
] || [];
useFetch(
async () => {
const realmModel = await adminClient.realms.findOne({ realm });
@ -55,20 +63,20 @@ export default function UserFederationSection() {
[key]
);
const ufAddProviderDropdownItems = [
<DropdownItem
key="itemLDAP"
onClick={() => history.push(`${url}/ldap/new`)}
>
LDAP
</DropdownItem>,
<DropdownItem
key="itemKerberos"
onClick={() => history.push(`${url}/kerberos/new`)}
>
Kerberos
</DropdownItem>,
];
const ufAddProviderDropdownItems = useMemo(
() =>
providers.map((p) => (
<DropdownItem
key={p.id}
onClick={() =>
history.push(toProvider({ realm, providerId: p.id!, id: "new" }))
}
>
{toUpperCase(p.id)}
</DropdownItem>
)),
[]
);
// const learnMoreLinkProps = {
// title: t("common:learnMore"),
@ -122,9 +130,7 @@ export default function UserFederationSection() {
dropdownItems={ufCardDropdownItems}
providerId={userFederation.providerId!}
title={userFederation.name!}
footerText={
userFederation.providerId === "ldap" ? "LDAP" : "Kerberos"
}
footerText={toUpperCase(userFederation.providerId!)}
labelText={
userFederation.config!["enabled"][0] !== "false"
? `${t("common:enabled")}`
@ -170,36 +176,31 @@ export default function UserFederationSection() {
</TextContent>
<hr className="pf-u-mb-lg" />
<Gallery hasGutter>
<Card
className="keycloak-empty-state-card"
isHoverable
onClick={() => history.push(`${url}/kerberos/new`)}
data-testid="kerberos-card"
>
<CardTitle>
<Split hasGutter>
<SplitItem>
<DatabaseIcon size="lg" />
</SplitItem>
<SplitItem isFilled>{t("addKerberos")}</SplitItem>
</Split>
</CardTitle>
</Card>
<Card
className="keycloak-empty-state-card"
isHoverable
onClick={() => history.push(`${url}/ldap/new`)}
data-testid="ldap-card"
>
<CardTitle>
<Split hasGutter>
<SplitItem>
<DatabaseIcon size="lg" />
</SplitItem>
<SplitItem isFilled>{t("addLdap")}</SplitItem>
</Split>
</CardTitle>
</Card>
{providers.map((p) => (
<Card
key={p.id}
className="keycloak-empty-state-card"
isHoverable
onClick={() =>
history.push(toProvider({ realm, providerId: p.id! }))
}
data-testid={`${p.id}-card`}
>
<CardTitle>
<Split hasGutter>
<SplitItem>
<DatabaseIcon size="lg" />
</SplitItem>
<SplitItem isFilled>
{t("addProvider", {
provider: toUpperCase(p.id!),
count: 4,
})}
</SplitItem>
</Split>
</CardTitle>
</Card>
))}
</Gallery>
</>
)}

View file

@ -0,0 +1,141 @@
import React from "react";
import { useHistory, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FormProvider, useForm } from "react-hook-form";
import {
ActionGroup,
AlertVariant,
Button,
FormGroup,
PageSection,
TextInput,
} from "@patternfly/react-core";
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import type { ProviderRouteParams } from "../routes/NewProvider";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { FormAccess } from "../../components/form-access/FormAccess";
import { toUserFederation } from "../routes/UserFederation";
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useAlerts } from "../../components/alert/Alerts";
import { SettingsCache } from "../shared/SettingsCache";
import { ExtendedHeader } from "../shared/ExtendedHeader";
import "./custom-provider-settings.css";
export default function CustomProviderSettings() {
const { t } = useTranslation("user-federation");
const { id, providerId } = useParams<ProviderRouteParams>();
const history = useHistory();
const form = useForm<ComponentRepresentation>({
mode: "onChange",
});
const {
register,
errors,
reset,
handleSubmit,
formState: { isDirty },
} = form;
const adminClient = useAdminClient();
const { addAlert, addError } = useAlerts();
const { realm } = useRealm();
useFetch(
async () => {
if (id) {
return await adminClient.components.findOne({ id });
}
return undefined;
},
(fetchedComponent) => {
if (fetchedComponent) {
reset({ ...fetchedComponent });
} else if (id) {
throw new Error(t("common:notFound"));
}
},
[]
);
const save = async (component: ComponentRepresentation) => {
const saveComponent = {
...component,
providerId,
providerType: "org.keycloak.storage.UserStorageProvider",
parentId: realm,
};
try {
if (!id) {
await adminClient.components.create(saveComponent);
history.push(toUserFederation({ realm }));
} else {
await adminClient.components.update({ id }, saveComponent);
}
reset({ ...component });
addAlert(t(!id ? "createSuccess" : "saveSuccess"), AlertVariant.success);
} catch (error) {
addError(`user-federation:${!id ? "createError" : "saveError"}`, error);
}
};
return (
<FormProvider {...form}>
<ExtendedHeader provider={providerId} save={() => handleSubmit(save)()} />
<PageSection variant="light">
<FormAccess
role="manage-realm"
isHorizontal
className="keycloak__user-federation__custom-form"
onSubmit={handleSubmit(save)}
>
<FormGroup
label={t("consoleDisplayName")}
labelIcon={
<HelpItem
helpText="users-federation-help:consoleDisplayNameHelp"
fieldLabelId="users-federation:consoleDisplayName"
/>
}
helperTextInvalid={t("validateName")}
validated={errors.name ? "error" : "default"}
fieldId="kc-console-display-name"
isRequired
>
<TextInput
isRequired
type="text"
id="kc-console-display-name"
name="name"
ref={register({
required: true,
})}
data-testid="console-name"
validated={errors.name ? "error" : "default"}
/>
</FormGroup>
<SettingsCache form={form} unWrap />
<ActionGroup>
<Button
isDisabled={!isDirty}
variant="primary"
type="submit"
data-testid="custom-save"
>
{t("common:save")}
</Button>
<Button
variant="link"
onClick={() => history.push(toUserFederation({ realm }))}
data-testid="custom-cancel"
>
{t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
</PageSection>
</FormProvider>
);
}

View file

@ -0,0 +1,3 @@
.keycloak__user-federation__custom-form {
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: 12rem;
}

View file

@ -7,9 +7,8 @@ export default {
"Keycloak can federate external user databases. Out of the box we have support for LDAP and Active Directory.",
getStarted: "To get started, select a provider from the list below.",
providers: "Add providers",
addKerberos: "Add Kerberos providers",
addLdap: "Add LDAP providers",
addOneLdap: "Add LDAP provider",
addProvider_one: "Add {{provider}} provider",
addProvider_other: "Add {{provider}} providers",
addKerberosWizardTitle: "Add Kerberos user federation provider",
addLdapWizardTitle: "Add LDAP user federation provider",
@ -97,6 +96,8 @@ export default {
learnMore: "Learn more",
addNewProvider: "Add new provider",
addCustomProvider: "Add custom provider",
providerDetails: "Provider details",
userFedDeletedSuccess: "The user federation provider has been deleted.",
userFedDeleteError:
"Could not delete user federation provider: '{{error}}'",

View file

@ -1,6 +1,10 @@
import type { RouteDef } from "../route-config";
import { NewKerberosUserFederationRoute } from "./routes/NewKerberosUserFederation";
import { NewLdapUserFederationRoute } from "./routes/NewLdapUserFederation";
import {
CustomEditProviderRoute,
CustomProviderRoute,
} from "./routes/NewProvider";
import { UserFederationRoute } from "./routes/UserFederation";
import { UserFederationKerberosRoute } from "./routes/UserFederationKerberos";
import { UserFederationLdapRoute } from "./routes/UserFederationLdap";
@ -17,6 +21,8 @@ const routes: RouteDef[] = [
NewLdapUserFederationRoute,
UserFederationLdapRoute,
UserFederationLdapMapperRoute,
CustomProviderRoute,
CustomEditProviderRoute,
];
export default routes;

View file

@ -8,7 +8,8 @@ export type NewLdapUserFederationParams = { realm: string };
export const NewLdapUserFederationRoute: RouteDef = {
path: "/:realm/user-federation/ldap/new",
component: lazy(() => import("../UserFederationLdapSettings")),
breadcrumb: (t) => t("user-federation:addOneLdap"),
breadcrumb: (t) =>
t("user-federation:addProvider", { provider: "LDAP", count: 1 }),
access: "view-realm",
};

View file

@ -0,0 +1,31 @@
import { lazy } from "react";
import { generatePath } from "react-router-dom";
import type { LocationDescriptorObject } from "history";
import type { RouteDef } from "../../route-config";
export type ProviderRouteParams = {
realm: string;
providerId: string;
id?: string;
};
export const CustomProviderRoute: RouteDef = {
path: "/:realm/user-federation/:providerId/new",
component: lazy(() => import("../custom/CustomProviderSettings")),
breadcrumb: (t) => t("user-federation:addCustomProvider"),
access: "view-realm",
};
export const CustomEditProviderRoute: RouteDef = {
path: "/:realm/user-federation/:providerId/:id",
component: lazy(() => import("../custom/CustomProviderSettings")),
breadcrumb: (t) => t("user-federation:providerDetails"),
access: "view-realm",
};
export const toProvider = (
params: ProviderRouteParams
): LocationDescriptorObject => ({
pathname: generatePath(CustomProviderRoute.path, params),
});

View file

@ -0,0 +1,150 @@
import React from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
DropdownItem,
DropdownSeparator,
} from "@patternfly/react-core";
import { useAlerts } from "../../components/alert/Alerts";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { useAdminClient } from "../../context/auth/AdminClient";
import { Header } from "./Header";
type ExtendedHeaderProps = {
provider: string;
editMode?: string | string[];
save: () => void;
noDivider?: boolean;
};
export const ExtendedHeader = ({
provider,
editMode,
save,
noDivider = false,
}: ExtendedHeaderProps) => {
const { t } = useTranslation("user-federation");
const { id } = useParams<{ id: string }>();
const adminClient = useAdminClient();
const { addAlert, addError } = useAlerts();
const [toggleUnlinkUsersDialog, UnlinkUsersDialog] = useConfirmDialog({
titleKey: "user-federation:userFedUnlinkUsersConfirmTitle",
messageKey: "user-federation:userFedUnlinkUsersConfirm",
continueButtonLabel: "user-federation:unlinkUsers",
onConfirm: () => unlinkUsers(),
});
const [toggleRemoveUsersDialog, RemoveUsersConfirm] = useConfirmDialog({
titleKey: t("removeImportedUsers"),
messageKey: t("removeImportedUsersMessage"),
continueButtonLabel: "common:remove",
onConfirm: async () => {
try {
removeImportedUsers();
addAlert(t("removeImportedUsersSuccess"), AlertVariant.success);
} catch (error) {
addError("user-federation:removeImportedUsersError", error);
}
},
});
const removeImportedUsers = async () => {
try {
if (id) {
await adminClient.userStorageProvider.removeImportedUsers({ id });
}
addAlert(t("removeImportedUsersSuccess"), AlertVariant.success);
} catch (error) {
addError("user-federation:removeImportedUsersError", error);
}
};
const syncChangedUsers = async () => {
try {
if (id) {
const response = await adminClient.userStorageProvider.sync({
id: id,
action: "triggerChangedUsersSync",
});
if (response.ignored) {
addAlert(`${response.status}.`, AlertVariant.warning);
} else {
addAlert(
t("syncUsersSuccess") +
`${response.added} users added, ${response.updated} users updated, ${response.removed} users removed, ${response.failed} users failed.`,
AlertVariant.success
);
}
}
} catch (error) {
addError("user-federation:syncUsersError", error);
}
};
const syncAllUsers = async () => {
try {
if (id) {
const response = await adminClient.userStorageProvider.sync({
id: id,
action: "triggerFullSync",
});
if (response.ignored) {
addAlert(`${response.status}.`, AlertVariant.warning);
} else {
addAlert(
t("syncUsersSuccess") +
`${response.added} users added, ${response.updated} users updated, ${response.removed} users removed, ${response.failed} users failed.`,
AlertVariant.success
);
}
}
} catch (error) {
addError("user-federation:syncUsersError", error);
}
};
const unlinkUsers = async () => {
try {
if (id) {
await adminClient.userStorageProvider.unlinkUsers({ id });
}
addAlert(t("unlinkUsersSuccess"), AlertVariant.success);
} catch (error) {
addError("user-federation:unlinkUsersError", error);
}
};
return (
<>
<UnlinkUsersDialog />
<RemoveUsersConfirm />
<Header
provider={provider}
noDivider={noDivider}
save={save}
dropdownItems={[
<DropdownItem key="sync" onClick={syncChangedUsers}>
{t("syncChangedUsers")}
</DropdownItem>,
<DropdownItem key="syncall" onClick={syncAllUsers}>
{t("syncAllUsers")}
</DropdownItem>,
<DropdownItem
key="unlink"
isDisabled={editMode ? !editMode.includes("UNSYNCED") : false}
onClick={toggleUnlinkUsersDialog}
>
{t("unlinkUsers")}
</DropdownItem>,
<DropdownItem key="remove" onClick={toggleRemoveUsersDialog}>
{t("removeImported")}
</DropdownItem>,
<DropdownSeparator key="separator" />,
]}
/>
</>
);
};

View file

@ -0,0 +1,113 @@
import React, { ReactElement } from "react";
import { useHistory, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
ButtonVariant,
DropdownItem,
} from "@patternfly/react-core";
import type { ProviderRouteParams } from "../routes/NewProvider";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts";
import { useRealm } from "../../context/realm-context/RealmContext";
import { toUserFederation } from "../routes/UserFederation";
import { Controller, useFormContext } from "react-hook-form";
type HeaderProps = {
provider: string;
save: () => void;
dropdownItems?: ReactElement[];
noDivider?: boolean;
};
export const Header = ({
provider,
save,
noDivider = false,
dropdownItems = [],
}: HeaderProps) => {
const { t } = useTranslation("user-federation");
const { id } = useParams<ProviderRouteParams>();
const history = useHistory();
const adminClient = useAdminClient();
const { addAlert, addError } = useAlerts();
const { realm } = useRealm();
const { control, setValue } = useFormContext();
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "user-federation:userFedDisableConfirmTitle",
messageKey: "user-federation:userFedDisableConfirm",
continueButtonLabel: "common:disable",
onConfirm: () => {
setValue("config.enabled[0]", "false");
save();
},
});
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "user-federation:userFedDeleteConfirmTitle",
messageKey: "user-federation:userFedDeleteConfirm",
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.components.del({ id: id! });
addAlert(t("userFedDeletedSuccess"), AlertVariant.success);
history.replace(toUserFederation({ realm }));
} catch (error) {
addError("user-federation:userFedDeleteError", error);
}
},
});
return (
<>
<DisableConfirm />
<DeleteConfirm />
<Controller
name="config.enabled[0]"
defaultValue={["true"][0]}
control={control}
render={({ onChange, value }) =>
!id ? (
<ViewHeader
titleKey={t("addProvider", {
provider: provider,
count: 1,
})}
/>
) : (
<ViewHeader
divider={!noDivider}
titleKey={provider}
dropdownItems={[
...dropdownItems,
<DropdownItem
key="delete"
onClick={() => toggleDeleteDialog()}
data-testid="delete-cmd"
>
{t("deleteProvider")}
</DropdownItem>,
]}
isEnabled={value === "true"}
onToggle={(value) => {
if (!value) {
toggleDisableDialog();
} else {
onChange(value.toString());
save();
}
}}
/>
)
}
/>
</>
);
};

View file

@ -6,44 +6,35 @@ import {
TextInput,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import React, { useState } from "react";
import React from "react";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { UseFormMethods, useWatch, Controller } from "react-hook-form";
import { FormAccess } from "../../components/form-access/FormAccess";
import _ from "lodash";
import { WizardSectionHeader } from "../../components/wizard-section-header/WizardSectionHeader";
import useToggle from "../../utils/useToggle";
export type SettingsCacheProps = {
form: UseFormMethods;
showSectionHeading?: boolean;
showSectionDescription?: boolean;
unWrap?: boolean;
};
export const SettingsCache = ({
form,
showSectionHeading = false,
showSectionDescription = false,
}: SettingsCacheProps) => {
const CacheFields = ({ form }: { form: UseFormMethods }) => {
const { t } = useTranslation("user-federation");
const { t: helpText } = useTranslation("user-federation-help");
const [isCachePolicyDropdownOpen, setIsCachePolicyDropdownOpen] =
useState(false);
const [isCachePolicyOpen, toggleCachePolicy] = useToggle();
const [isEvictionHourOpen, toggleEvictionHour] = useToggle();
const [isEvictionMinuteOpen, toggleEvictionMinute] = useToggle();
const [isEvictionHourDropdownOpen, setIsEvictionHourDropdownOpen] =
useState(false);
const [isEvictionDayOpen, toggleEvictionDay] = useToggle();
const cachePolicyType = useWatch({
control: form.control,
name: "config.cachePolicy",
});
const [isEvictionMinuteDropdownOpen, setIsEvictionMinuteDropdownOpen] =
useState(false);
const [isEvictionDayDropdownOpen, setIsEvictionDayDropdownOpen] =
useState(false);
const hourOptions = [
<SelectOption key={0} value={[`${0}`]} isPlaceholder>
{[`0${0}`]}
@ -82,6 +73,203 @@ export const SettingsCache = ({
);
}
return (
<>
<FormGroup
label={t("cachePolicy")}
labelIcon={
<HelpItem
helpText="user-federation-help:cachePolicyHelp"
fieldLabelId="user-federation:cachePolicy"
/>
}
fieldId="kc-cache-policy"
>
<Controller
name="config.cachePolicy"
defaultValue={["DEFAULT"]}
control={form.control}
render={({ onChange, value }) => (
<Select
toggleId="kc-cache-policy"
required
onToggle={toggleCachePolicy}
isOpen={isCachePolicyOpen}
onSelect={(_, value) => {
onChange(value as string);
toggleCachePolicy();
}}
selections={value}
variant={SelectVariant.single}
data-testid="kerberos-cache-policy"
>
<SelectOption key={0} value={["DEFAULT"]} isPlaceholder />
<SelectOption key={1} value={["EVICT_DAILY"]} />
<SelectOption key={2} value={["EVICT_WEEKLY"]} />
<SelectOption key={3} value={["MAX_LIFESPAN"]} />
<SelectOption key={4} value={["NO_CACHE"]} />
</Select>
)}
/>
</FormGroup>
{_.isEqual(cachePolicyType, ["EVICT_WEEKLY"]) ? (
<FormGroup
label={t("evictionDay")}
labelIcon={
<HelpItem
helpText="user-federation-help:evictionDayHelp"
fieldLabelId="user-federation:evictionDay"
/>
}
isRequired
fieldId="kc-eviction-day"
>
<Controller
name="config.evictionDay[0]"
defaultValue={"1"}
control={form.control}
render={({ onChange, value }) => (
<Select
data-testid="cache-day"
toggleId="kc-eviction-day"
required
onToggle={toggleEvictionDay}
isOpen={isEvictionDayOpen}
onSelect={(_, value) => {
onChange(value as string);
toggleEvictionDay();
}}
selections={value}
variant={SelectVariant.single}
>
<SelectOption key={0} value="1" isPlaceholder>
{t("common:Sunday")}
</SelectOption>
<SelectOption key={1} value="2">
{t("common:Monday")}
</SelectOption>
<SelectOption key={2} value="3">
{t("common:Tuesday")}
</SelectOption>
<SelectOption key={3} value="4">
{t("common:Wednesday")}
</SelectOption>
<SelectOption key={4} value="5">
{t("common:Thursday")}
</SelectOption>
<SelectOption key={5} value="6">
{t("common:Friday")}
</SelectOption>
<SelectOption key={6} value="7">
{t("common:Saturday")}
</SelectOption>
</Select>
)}
/>
</FormGroup>
) : null}
{_.isEqual(cachePolicyType, ["EVICT_DAILY"]) ||
_.isEqual(cachePolicyType, ["EVICT_WEEKLY"]) ? (
<>
<FormGroup
label={t("evictionHour")}
labelIcon={
<HelpItem
helpText="user-federation-help:evictionHourHelp"
fieldLabelId="user-federation:evictionHour"
/>
}
isRequired
fieldId="kc-eviction-hour"
>
<Controller
name="config.evictionHour"
defaultValue={["0"]}
control={form.control}
render={({ onChange, value }) => (
<Select
toggleId="kc-eviction-hour"
onToggle={toggleEvictionHour}
isOpen={isEvictionHourOpen}
onSelect={(_, value) => {
onChange(value as string);
toggleEvictionHour();
}}
selections={value}
variant={SelectVariant.single}
>
{hourOptions}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("evictionMinute")}
labelIcon={
<HelpItem
helpText="user-federation-help:evictionMinuteHelp"
fieldLabelId="user-federation:evictionMinute"
/>
}
isRequired
fieldId="kc-eviction-minute"
>
<Controller
name="config.evictionMinute"
defaultValue={["0"]}
control={form.control}
render={({ onChange, value }) => (
<Select
toggleId="kc-eviction-minute"
onToggle={toggleEvictionMinute}
isOpen={isEvictionMinuteOpen}
onSelect={(_, value) => {
onChange(value as string);
toggleEvictionMinute();
}}
selections={value}
variant={SelectVariant.single}
>
{minuteOptions}
</Select>
)}
/>
</FormGroup>
</>
) : null}
{_.isEqual(cachePolicyType, ["MAX_LIFESPAN"]) ? (
<FormGroup
label={t("maxLifespan")}
labelIcon={
<HelpItem
helpText="user-federation-help:maxLifespanHelp"
fieldLabelId="user-federation:maxLifespan"
/>
}
fieldId="kc-max-lifespan"
>
<TextInput
type="text"
id="kc-max-lifespan"
name="config.maxLifespan[0]"
ref={form.register}
data-testid="kerberos-cache-lifespan"
/>
</FormGroup>
) : null}
</>
);
};
export const SettingsCache = ({
form,
showSectionHeading = false,
showSectionDescription = false,
unWrap = false,
}: SettingsCacheProps) => {
const { t } = useTranslation("user-federation");
const { t: helpText } = useTranslation("user-federation-help");
return (
<>
{showSectionHeading && (
@ -91,200 +279,13 @@ export const SettingsCache = ({
showDescription={showSectionDescription}
/>
)}
<FormAccess role="manage-realm" isHorizontal>
<FormGroup
label={t("cachePolicy")}
labelIcon={
<HelpItem
helpText="user-federation-help:cachePolicyHelp"
fieldLabelId="user-federation:cachePolicy"
/>
}
fieldId="kc-cache-policy"
>
<Controller
name="config.cachePolicy"
defaultValue={["DEFAULT"]}
control={form.control}
render={({ onChange, value }) => (
<Select
toggleId="kc-cache-policy"
required
onToggle={() =>
setIsCachePolicyDropdownOpen(!isCachePolicyDropdownOpen)
}
isOpen={isCachePolicyDropdownOpen}
onSelect={(_, value) => {
onChange(value as string);
setIsCachePolicyDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.single}
data-testid="kerberos-cache-policy"
>
<SelectOption key={0} value={["DEFAULT"]} isPlaceholder />
<SelectOption key={1} value={["EVICT_DAILY"]} />
<SelectOption key={2} value={["EVICT_WEEKLY"]} />
<SelectOption key={3} value={["MAX_LIFESPAN"]} />
<SelectOption key={4} value={["NO_CACHE"]} />
</Select>
)}
></Controller>
</FormGroup>
{_.isEqual(cachePolicyType, ["EVICT_WEEKLY"]) ? (
<FormGroup
label={t("evictionDay")}
labelIcon={
<HelpItem
helpText="user-federation-help:evictionDayHelp"
fieldLabelId="user-federation:evictionDay"
/>
}
isRequired
fieldId="kc-eviction-day"
>
<Controller
name="config.evictionDay[0]"
defaultValue={"1"}
control={form.control}
render={({ onChange, value }) => (
<Select
data-testid="cache-day"
toggleId="kc-eviction-day"
required
onToggle={() =>
setIsEvictionDayDropdownOpen(!isEvictionDayDropdownOpen)
}
isOpen={isEvictionDayDropdownOpen}
onSelect={(_, value) => {
onChange(value as string);
setIsEvictionDayDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.single}
>
<SelectOption key={0} value="1" isPlaceholder>
{t("common:Sunday")}
</SelectOption>
<SelectOption key={1} value="2">
{t("common:Monday")}
</SelectOption>
<SelectOption key={2} value="3">
{t("common:Tuesday")}
</SelectOption>
<SelectOption key={3} value="4">
{t("common:Wednesday")}
</SelectOption>
<SelectOption key={4} value="5">
{t("common:Thursday")}
</SelectOption>
<SelectOption key={5} value="6">
{t("common:Friday")}
</SelectOption>
<SelectOption key={6} value="7">
{t("common:Saturday")}
</SelectOption>
</Select>
)}
></Controller>
</FormGroup>
) : null}
{_.isEqual(cachePolicyType, ["EVICT_DAILY"]) ||
_.isEqual(cachePolicyType, ["EVICT_WEEKLY"]) ? (
<>
<FormGroup
label={t("evictionHour")}
labelIcon={
<HelpItem
helpText="user-federation-help:evictionHourHelp"
fieldLabelId="user-federation:evictionHour"
/>
}
isRequired
fieldId="kc-eviction-hour"
>
<Controller
name="config.evictionHour"
defaultValue={["0"]}
control={form.control}
render={({ onChange, value }) => (
<Select
toggleId="kc-eviction-hour"
onToggle={() =>
setIsEvictionHourDropdownOpen(!isEvictionHourDropdownOpen)
}
isOpen={isEvictionHourDropdownOpen}
onSelect={(_, value) => {
onChange(value as string);
setIsEvictionHourDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.single}
>
{hourOptions}
</Select>
)}
></Controller>
</FormGroup>
<FormGroup
label={t("evictionMinute")}
labelIcon={
<HelpItem
helpText="user-federation-help:evictionMinuteHelp"
fieldLabelId="user-federation:evictionMinute"
/>
}
isRequired
fieldId="kc-eviction-minute"
>
<Controller
name="config.evictionMinute"
defaultValue={["0"]}
control={form.control}
render={({ onChange, value }) => (
<Select
toggleId="kc-eviction-minute"
onToggle={() =>
setIsEvictionMinuteDropdownOpen(
!isEvictionMinuteDropdownOpen
)
}
isOpen={isEvictionMinuteDropdownOpen}
onSelect={(_, value) => {
onChange(value as string);
setIsEvictionMinuteDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.single}
>
{minuteOptions}
</Select>
)}
></Controller>
</FormGroup>
</>
) : null}
{_.isEqual(cachePolicyType, ["MAX_LIFESPAN"]) ? (
<FormGroup
label={t("maxLifespan")}
labelIcon={
<HelpItem
helpText="user-federation-help:maxLifespanHelp"
fieldLabelId="user-federation:maxLifespan"
/>
}
fieldId="kc-max-lifespan"
>
<TextInput
type="text"
id="kc-max-lifespan"
name="config.maxLifespan[0]"
ref={form.register}
data-testid="kerberos-cache-lifespan"
/>
</FormGroup>
) : null}
</FormAccess>
{unWrap ? (
<CacheFields form={form} />
) : (
<FormAccess role="manage-realm" isHorizontal>
<CacheFields form={form} />
</FormAccess>
)}
</>
);
};