initial version of the password policies (#1601)
This commit is contained in:
parent
0bbd4ddad1
commit
de1d677cc1
13 changed files with 564 additions and 2 deletions
|
@ -7,6 +7,7 @@ import DuplicateFlowModal from "../support/pages/admin_console/manage/authentica
|
||||||
import FlowDetails from "../support/pages/admin_console/manage/authentication/FlowDetail";
|
import FlowDetails from "../support/pages/admin_console/manage/authentication/FlowDetail";
|
||||||
import RequiredActions from "../support/pages/admin_console/manage/authentication/RequiredActions";
|
import RequiredActions from "../support/pages/admin_console/manage/authentication/RequiredActions";
|
||||||
import AdminClient from "../support/util/AdminClient";
|
import AdminClient from "../support/util/AdminClient";
|
||||||
|
import PasswordPolicies from "../support/pages/admin_console/manage/authentication/PasswordPolicies";
|
||||||
|
|
||||||
describe("Authentication test", () => {
|
describe("Authentication test", () => {
|
||||||
const loginPage = new LoginPage();
|
const loginPage = new LoginPage();
|
||||||
|
@ -154,4 +155,32 @@ describe("Authentication test", () => {
|
||||||
masthead.checkNotificationMessage("Updated required action successfully");
|
masthead.checkNotificationMessage("Updated required action successfully");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Password policies tab", () => {
|
||||||
|
const passwordPoliciesPage = new PasswordPolicies();
|
||||||
|
beforeEach(() => {
|
||||||
|
keycloakBefore();
|
||||||
|
loginPage.logIn();
|
||||||
|
sidebarPage.goToAuthentication();
|
||||||
|
passwordPoliciesPage.goToTab();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add password policies", () => {
|
||||||
|
passwordPoliciesPage
|
||||||
|
.shouldShowEmptyState()
|
||||||
|
.addPolicy("Not Recently Used")
|
||||||
|
.save();
|
||||||
|
masthead.checkNotificationMessage(
|
||||||
|
"Password policies successfully updated"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove password policies", () => {
|
||||||
|
passwordPoliciesPage.removePolicy("remove-passwordHistory").save();
|
||||||
|
masthead.checkNotificationMessage(
|
||||||
|
"Password policies successfully updated"
|
||||||
|
);
|
||||||
|
passwordPoliciesPage.shouldShowEmptyState();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
export default class PasswordPolicies {
|
||||||
|
goToTab() {
|
||||||
|
cy.get("#pf-tab-policies-policies").click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldShowEmptyState() {
|
||||||
|
cy.findByTestId("empty-state").should("exist");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addPolicy(name: string) {
|
||||||
|
cy.get(".pf-c-select").click().contains(name).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
removePolicy(name: string) {
|
||||||
|
cy.findByTestId(name).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
cy.findByTestId("save").click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,6 +29,7 @@ import { DuplicateFlowModal } from "./DuplicateFlowModal";
|
||||||
import { toCreateFlow } from "./routes/CreateFlow";
|
import { toCreateFlow } from "./routes/CreateFlow";
|
||||||
import { toFlow } from "./routes/Flow";
|
import { toFlow } from "./routes/Flow";
|
||||||
import { RequiredActions } from "./RequiredActions";
|
import { RequiredActions } from "./RequiredActions";
|
||||||
|
import { Policies } from "./policies/Policies";
|
||||||
|
|
||||||
import "./authentication-section.css";
|
import "./authentication-section.css";
|
||||||
|
|
||||||
|
@ -290,6 +291,13 @@ export default function AuthenticationSection() {
|
||||||
>
|
>
|
||||||
<RequiredActions />
|
<RequiredActions />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
id="policies"
|
||||||
|
eventKey="policies"
|
||||||
|
title={<TabTitleText>{t("policies")}</TabTitleText>}
|
||||||
|
>
|
||||||
|
<Policies />
|
||||||
|
</Tab>
|
||||||
</KeycloakTabs>
|
</KeycloakTabs>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -74,7 +74,7 @@ export const EmptyExecutionState = ({
|
||||||
<Flex alignSelf={{ default: "alignSelfCenter" }}>
|
<Flex alignSelf={{ default: "alignSelfCenter" }}>
|
||||||
<FlexItem>
|
<FlexItem>
|
||||||
<Button
|
<Button
|
||||||
data-testId={section}
|
data-testid={section}
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
onClick={() => setShow(section)}
|
onClick={() => setShow(section)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -3,6 +3,18 @@ export default {
|
||||||
title: "Authentication",
|
title: "Authentication",
|
||||||
flows: "Flows",
|
flows: "Flows",
|
||||||
requiredActions: "Required actions",
|
requiredActions: "Required actions",
|
||||||
|
policies: "Policies",
|
||||||
|
passwordPolicy: "Password policy",
|
||||||
|
otpPolicy: "OTP Policy",
|
||||||
|
webauthnPolicy: "Webauthn Policy",
|
||||||
|
webauthnPasswordlessPolicy: "Webauthn Passwordless Policy",
|
||||||
|
noPasswordPolicies: "No password policies",
|
||||||
|
noPasswordPoliciesInstructions:
|
||||||
|
"You haven't added any password policies to this realm. Add a policy to get started.",
|
||||||
|
updatePasswordPolicySuccess: "Password policies successfully updated",
|
||||||
|
updatePasswordPolicyError:
|
||||||
|
"Could not update the password policies: '{{error}}'",
|
||||||
|
addPolicy: "Add policy",
|
||||||
flowName: "Flow name",
|
flowName: "Flow name",
|
||||||
searchForFlow: "Search for flow",
|
searchForFlow: "Search for flow",
|
||||||
usedBy: "Used by",
|
usedBy: "Used by",
|
||||||
|
|
197
src/authentication/policies/PasswordPolicy.tsx
Normal file
197
src/authentication/policies/PasswordPolicy.tsx
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActionGroup,
|
||||||
|
AlertVariant,
|
||||||
|
Button,
|
||||||
|
ButtonVariant,
|
||||||
|
Divider,
|
||||||
|
EmptyState,
|
||||||
|
EmptyStateBody,
|
||||||
|
EmptyStateIcon,
|
||||||
|
EmptyStatePrimary,
|
||||||
|
PageSection,
|
||||||
|
Select,
|
||||||
|
SelectOption,
|
||||||
|
Title,
|
||||||
|
Toolbar,
|
||||||
|
ToolbarContent,
|
||||||
|
ToolbarItem,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
import { PlusCircleIcon } from "@patternfly/react-icons";
|
||||||
|
|
||||||
|
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
|
import type PasswordPolicyTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/passwordPolicyTypeRepresentation";
|
||||||
|
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
||||||
|
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
|
||||||
|
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||||
|
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||||
|
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||||
|
import { useAlerts } from "../../components/alert/Alerts";
|
||||||
|
import { parsePolicy, SubmittedValues } from "./util";
|
||||||
|
import { PolicyRow } from "./PolicyRow";
|
||||||
|
import { serializePolicy } from "./util";
|
||||||
|
|
||||||
|
type PolicySelectProps = {
|
||||||
|
onSelect: (row: PasswordPolicyTypeRepresentation) => void;
|
||||||
|
selectedPolicies: PasswordPolicyTypeRepresentation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PolicySelect = ({ onSelect, selectedPolicies }: PolicySelectProps) => {
|
||||||
|
const { t } = useTranslation("authentication");
|
||||||
|
const { passwordPolicies } = useServerInfo();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const policies = useMemo(
|
||||||
|
() =>
|
||||||
|
passwordPolicies?.filter(
|
||||||
|
(p) => selectedPolicies.find((o) => o.id === p.id) === undefined
|
||||||
|
),
|
||||||
|
[selectedPolicies]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
width={300}
|
||||||
|
onSelect={(_, selection) => {
|
||||||
|
onSelect(selection as PasswordPolicyTypeRepresentation);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
onToggle={(value) => setOpen(value)}
|
||||||
|
isOpen={open}
|
||||||
|
selections={t("addPolicy")}
|
||||||
|
isDisabled={policies?.length === 0}
|
||||||
|
>
|
||||||
|
{policies?.map((policy) => (
|
||||||
|
<SelectOption key={policy.id} value={policy}>
|
||||||
|
{policy.displayName}
|
||||||
|
</SelectOption>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PasswordPolicy = () => {
|
||||||
|
const { t } = useTranslation("authentication");
|
||||||
|
const { passwordPolicies } = useServerInfo();
|
||||||
|
|
||||||
|
const adminClient = useAdminClient();
|
||||||
|
const { realm: realmName } = useRealm();
|
||||||
|
const { addAlert, addError } = useAlerts();
|
||||||
|
|
||||||
|
const [realm, setRealm] = useState<RealmRepresentation>();
|
||||||
|
const [rows, setRows] = useState<PasswordPolicyTypeRepresentation[]>([]);
|
||||||
|
const onSelect = (row: PasswordPolicyTypeRepresentation) =>
|
||||||
|
setRows([...rows, row]);
|
||||||
|
|
||||||
|
const form = useForm<SubmittedValues>({ shouldUnregister: false });
|
||||||
|
const { handleSubmit, setValue, getValues } = form;
|
||||||
|
|
||||||
|
const setupForm = (realm: RealmRepresentation) => {
|
||||||
|
const values = parsePolicy(realm.passwordPolicy || "", passwordPolicies!);
|
||||||
|
values.forEach((v) => {
|
||||||
|
setValue(v.id!, v.value);
|
||||||
|
});
|
||||||
|
setRows(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
useFetch(
|
||||||
|
async () => {
|
||||||
|
const realm = await adminClient.realms.findOne({ realm: realmName });
|
||||||
|
if (!realm) {
|
||||||
|
throw new Error(t("common:notFound"));
|
||||||
|
}
|
||||||
|
return realm;
|
||||||
|
},
|
||||||
|
(realm) => {
|
||||||
|
setRealm(realm);
|
||||||
|
setupForm(realm);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = async (values: SubmittedValues) => {
|
||||||
|
const updatedRealm = {
|
||||||
|
...realm,
|
||||||
|
passwordPolicy: serializePolicy(rows, values),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await adminClient.realms.update({ realm: realmName }, updatedRealm);
|
||||||
|
setRealm(updatedRealm);
|
||||||
|
addAlert(t("updatePasswordPolicySuccess"), AlertVariant.success);
|
||||||
|
} catch (error: any) {
|
||||||
|
addError("authentication:updatePasswordPolicyError", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!realm) {
|
||||||
|
return <KeycloakSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection variant="light" className="pf-u-p-0">
|
||||||
|
{(rows.length !== 0 || realm.passwordPolicy) && (
|
||||||
|
<>
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarContent>
|
||||||
|
<ToolbarItem>
|
||||||
|
<PolicySelect onSelect={onSelect} selectedPolicies={rows} />
|
||||||
|
</ToolbarItem>
|
||||||
|
</ToolbarContent>
|
||||||
|
</Toolbar>
|
||||||
|
<Divider />
|
||||||
|
<PageSection variant="light">
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<FormAccess
|
||||||
|
role="manage-realm"
|
||||||
|
isHorizontal
|
||||||
|
onSubmit={handleSubmit(save)}
|
||||||
|
>
|
||||||
|
{rows.map((r, index) => (
|
||||||
|
<PolicyRow
|
||||||
|
key={`${r.id}-${index}`}
|
||||||
|
policy={r}
|
||||||
|
onRemove={(id) => setRows(rows.filter((r) => r.id !== id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ActionGroup>
|
||||||
|
<Button
|
||||||
|
data-testid="save"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
isDisabled={
|
||||||
|
serializePolicy(rows, getValues()) ===
|
||||||
|
realm.passwordPolicy
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("common:save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="reload"
|
||||||
|
variant={ButtonVariant.link}
|
||||||
|
onClick={() => setupForm(realm)}
|
||||||
|
>
|
||||||
|
{t("common:reload")}
|
||||||
|
</Button>
|
||||||
|
</ActionGroup>
|
||||||
|
</FormAccess>
|
||||||
|
</FormProvider>
|
||||||
|
</PageSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!rows.length && !realm.passwordPolicy && (
|
||||||
|
<EmptyState data-testid="empty-state" variant="large">
|
||||||
|
<EmptyStateIcon icon={PlusCircleIcon} />
|
||||||
|
<Title headingLevel="h1" size="lg">
|
||||||
|
{t("noPasswordPolicies")}
|
||||||
|
</Title>
|
||||||
|
<EmptyStateBody>{t("noPasswordPoliciesInstructions")}</EmptyStateBody>
|
||||||
|
<EmptyStatePrimary>
|
||||||
|
<PolicySelect onSelect={onSelect} selectedPolicies={[]} />
|
||||||
|
</EmptyStatePrimary>
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
};
|
40
src/authentication/policies/Policies.tsx
Normal file
40
src/authentication/policies/Policies.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Tab, Tabs, TabTitleText } from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import { PasswordPolicy } from "./PasswordPolicy";
|
||||||
|
|
||||||
|
export const Policies = () => {
|
||||||
|
const { t } = useTranslation("authentication");
|
||||||
|
const [subTab, setSubTab] = useState(1);
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
activeKey={subTab}
|
||||||
|
onSelect={(_, key) => setSubTab(key as number)}
|
||||||
|
mountOnEnter
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
id="passwordPolicy"
|
||||||
|
eventKey={1}
|
||||||
|
title={<TabTitleText>{t("passwordPolicy")}</TabTitleText>}
|
||||||
|
>
|
||||||
|
<PasswordPolicy />
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
id="otpPolicy"
|
||||||
|
eventKey={2}
|
||||||
|
title={<TabTitleText>{t("otpPolicy")}</TabTitleText>}
|
||||||
|
></Tab>
|
||||||
|
<Tab
|
||||||
|
id="webauthnPolicy"
|
||||||
|
eventKey={3}
|
||||||
|
title={<TabTitleText>{t("webauthnPolicy")}</TabTitleText>}
|
||||||
|
></Tab>
|
||||||
|
<Tab
|
||||||
|
id="webauthnPasswordlessPolicy"
|
||||||
|
eventKey={4}
|
||||||
|
title={<TabTitleText>{t("webauthnPasswordlessPolicy")}</TabTitleText>}
|
||||||
|
></Tab>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
};
|
105
src/authentication/policies/PolicyRow.tsx
Normal file
105
src/authentication/policies/PolicyRow.tsx
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormGroup,
|
||||||
|
NumberInput,
|
||||||
|
Split,
|
||||||
|
SplitItem,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
ValidatedOptions,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
import { MinusCircleIcon } from "@patternfly/react-icons";
|
||||||
|
|
||||||
|
import type PasswordPolicyTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/passwordPolicyTypeRepresentation";
|
||||||
|
|
||||||
|
import "./policy-row.css";
|
||||||
|
|
||||||
|
type PolicyRowProps = {
|
||||||
|
policy: PasswordPolicyTypeRepresentation;
|
||||||
|
onRemove: (id?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PolicyRow = ({
|
||||||
|
policy: { id, configType, defaultValue, displayName },
|
||||||
|
onRemove,
|
||||||
|
}: PolicyRowProps) => {
|
||||||
|
const { t } = useTranslation("authentication");
|
||||||
|
const { control, register, errors } = useFormContext();
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
label={displayName}
|
||||||
|
fieldId={id!}
|
||||||
|
isRequired
|
||||||
|
helperTextInvalid={t("common:required")}
|
||||||
|
validated={
|
||||||
|
errors[id!] ? ValidatedOptions.error : ValidatedOptions.default
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Split>
|
||||||
|
<SplitItem isFilled>
|
||||||
|
{configType && configType !== "int" && (
|
||||||
|
<TextInput
|
||||||
|
id={id}
|
||||||
|
data-testid={id}
|
||||||
|
ref={register({ required: true })}
|
||||||
|
name={id}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
validated={
|
||||||
|
errors[id!] ? ValidatedOptions.error : ValidatedOptions.default
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{configType === "int" && (
|
||||||
|
<Controller
|
||||||
|
name={id!}
|
||||||
|
defaultValue={Number.parseInt(defaultValue || "0")}
|
||||||
|
control={control}
|
||||||
|
render={({ onChange, value }) => {
|
||||||
|
const MIN_VALUE = 0;
|
||||||
|
const setValue = (newValue: number) =>
|
||||||
|
onChange(Math.max(newValue, MIN_VALUE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
min={MIN_VALUE}
|
||||||
|
onPlus={() => setValue(value + 1)}
|
||||||
|
onMinus={() => setValue(value - 1)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const newValue = Number(event.currentTarget.value);
|
||||||
|
setValue(!isNaN(newValue) ? newValue : 0);
|
||||||
|
}}
|
||||||
|
className="keycloak__policies_authentication__number-field"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!configType && (
|
||||||
|
<Switch
|
||||||
|
id={id!}
|
||||||
|
label={t("common:on")}
|
||||||
|
labelOff={t("common:off")}
|
||||||
|
isChecked
|
||||||
|
isDisabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SplitItem>
|
||||||
|
<SplitItem>
|
||||||
|
<Button
|
||||||
|
data-testid={`remove-${id}`}
|
||||||
|
variant="link"
|
||||||
|
className="keycloak__policies_authentication__minus-icon"
|
||||||
|
onClick={() => onRemove(id)}
|
||||||
|
>
|
||||||
|
<MinusCircleIcon />
|
||||||
|
</Button>
|
||||||
|
</SplitItem>
|
||||||
|
</Split>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
};
|
8
src/authentication/policies/policy-row.css
Normal file
8
src/authentication/policies/policy-row.css
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
|
||||||
|
.keycloak__policies_authentication__minus-icon svg {
|
||||||
|
color: var(--pf-c-button--m-plain--Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keycloak__policies_authentication__number-field {
|
||||||
|
--pf-c-number-input--c-form-control--Width: 7ch;
|
||||||
|
}
|
76
src/authentication/policies/util.test.ts
Normal file
76
src/authentication/policies/util.test.ts
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import type PasswordPolicyTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/passwordPolicyTypeRepresentation";
|
||||||
|
import { parsePolicy, serializePolicy, SubmittedValues } from "./util";
|
||||||
|
|
||||||
|
describe("serializePolicy", () => {
|
||||||
|
it("returns an empty string if there are no policies", () => {
|
||||||
|
expect(serializePolicy([], {})).toEqual("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes the policies", () => {
|
||||||
|
const policies: PasswordPolicyTypeRepresentation[] = [
|
||||||
|
{ id: "one" },
|
||||||
|
{ id: "two" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const submittedValues: SubmittedValues = {
|
||||||
|
one: "value1",
|
||||||
|
two: "value2",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(serializePolicy(policies, submittedValues)).toEqual(
|
||||||
|
"one(value1) and two(value2)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePolicy", () => {
|
||||||
|
it("returns an empty array if an empty value is passed", () => {
|
||||||
|
expect(parsePolicy("", [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the policy", () => {
|
||||||
|
const policies: PasswordPolicyTypeRepresentation[] = [
|
||||||
|
{ id: "one" },
|
||||||
|
{ id: "two" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(parsePolicy("one(value1) and two", policies)).toEqual([
|
||||||
|
{ id: "one", value: "value1" },
|
||||||
|
{ id: "two" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the policy and trims excessive whitespace", () => {
|
||||||
|
const policies: PasswordPolicyTypeRepresentation[] = [
|
||||||
|
{ id: "one" },
|
||||||
|
{ id: "two" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(parsePolicy("one( value1 ) and two ", policies)).toEqual([
|
||||||
|
{ id: "one", value: "value1" },
|
||||||
|
{ id: "two" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the policy and it handles unescaped values", () => {
|
||||||
|
const policies: PasswordPolicyTypeRepresentation[] = [{ id: "one" }];
|
||||||
|
|
||||||
|
expect(parsePolicy("one(value1", policies)).toEqual([{ id: "one" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the policy and preserves nested parentheses", () => {
|
||||||
|
const policies: PasswordPolicyTypeRepresentation[] = [{ id: "one" }];
|
||||||
|
|
||||||
|
expect(parsePolicy("one(value1))", policies)).toEqual([
|
||||||
|
{ id: "one", value: "value1)" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the policy and preserves only existing entries", () => {
|
||||||
|
const policies: PasswordPolicyTypeRepresentation[] = [{ id: "two" }];
|
||||||
|
|
||||||
|
expect(parsePolicy("one(value1) and two", policies)).toEqual([
|
||||||
|
{ id: "two" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
60
src/authentication/policies/util.ts
Normal file
60
src/authentication/policies/util.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import type PasswordPolicyTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/passwordPolicyTypeRepresentation";
|
||||||
|
|
||||||
|
export type SubmittedValues = {
|
||||||
|
[index: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLICY_SEPARATOR = " and ";
|
||||||
|
|
||||||
|
export const serializePolicy = (
|
||||||
|
policies: PasswordPolicyTypeRepresentation[],
|
||||||
|
submitted: SubmittedValues
|
||||||
|
) =>
|
||||||
|
policies
|
||||||
|
.map((policy) => `${policy.id}(${submitted[policy.id!]})`)
|
||||||
|
.join(POLICY_SEPARATOR);
|
||||||
|
|
||||||
|
type PolicyValue = PasswordPolicyTypeRepresentation & {
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parsePolicy = (
|
||||||
|
value: string,
|
||||||
|
policies: PasswordPolicyTypeRepresentation[]
|
||||||
|
) =>
|
||||||
|
value
|
||||||
|
.split(POLICY_SEPARATOR)
|
||||||
|
.map(parsePolicyToken)
|
||||||
|
.reduce<PolicyValue[]>((result, { id, value }) => {
|
||||||
|
const matchingPolicy = policies.find((policy) => policy.id === id);
|
||||||
|
|
||||||
|
if (!matchingPolicy) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.concat({ ...matchingPolicy, value });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
type PolicyTokenParsed = {
|
||||||
|
id: string;
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parsePolicyToken(token: string): PolicyTokenParsed {
|
||||||
|
const valueStart = token.indexOf("(");
|
||||||
|
|
||||||
|
if (valueStart === -1) {
|
||||||
|
return { id: token.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = token.substring(0, valueStart).trim();
|
||||||
|
const valueEnd = token.lastIndexOf(")");
|
||||||
|
|
||||||
|
if (valueEnd === -1) {
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = token.substring(valueStart + 1, valueEnd).trim();
|
||||||
|
|
||||||
|
return { id, value };
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ export default {
|
||||||
save: "Save",
|
save: "Save",
|
||||||
revert: "Revert",
|
revert: "Revert",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
|
reload: "Reload",
|
||||||
continue: "Continue",
|
continue: "Continue",
|
||||||
close: "Close",
|
close: "Close",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
|
|
|
@ -39,7 +39,7 @@ export const Time = ({
|
||||||
rules={{ required: true }}
|
rules={{ required: true }}
|
||||||
render={({ onChange, value }) => (
|
render={({ onChange, value }) => (
|
||||||
<TimeSelector
|
<TimeSelector
|
||||||
data-testId={name}
|
data-testid={name}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
validated={
|
validated={
|
||||||
|
|
Loading…
Reference in a new issue