diff --git a/cypress/integration/authentication_test.spec.ts b/cypress/integration/authentication_test.spec.ts index ae9f4628ec..c3a049d7ff 100644 --- a/cypress/integration/authentication_test.spec.ts +++ b/cypress/integration/authentication_test.spec.ts @@ -7,6 +7,7 @@ import DuplicateFlowModal from "../support/pages/admin_console/manage/authentica import FlowDetails from "../support/pages/admin_console/manage/authentication/FlowDetail"; import RequiredActions from "../support/pages/admin_console/manage/authentication/RequiredActions"; import AdminClient from "../support/util/AdminClient"; +import PasswordPolicies from "../support/pages/admin_console/manage/authentication/PasswordPolicies"; describe("Authentication test", () => { const loginPage = new LoginPage(); @@ -154,4 +155,32 @@ describe("Authentication test", () => { 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(); + }); + }); }); diff --git a/cypress/support/pages/admin_console/manage/authentication/PasswordPolicies.ts b/cypress/support/pages/admin_console/manage/authentication/PasswordPolicies.ts new file mode 100644 index 0000000000..7f9a0ede89 --- /dev/null +++ b/cypress/support/pages/admin_console/manage/authentication/PasswordPolicies.ts @@ -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; + } +} diff --git a/src/authentication/AuthenticationSection.tsx b/src/authentication/AuthenticationSection.tsx index d5604fa163..651e271975 100644 --- a/src/authentication/AuthenticationSection.tsx +++ b/src/authentication/AuthenticationSection.tsx @@ -29,6 +29,7 @@ import { DuplicateFlowModal } from "./DuplicateFlowModal"; import { toCreateFlow } from "./routes/CreateFlow"; import { toFlow } from "./routes/Flow"; import { RequiredActions } from "./RequiredActions"; +import { Policies } from "./policies/Policies"; import "./authentication-section.css"; @@ -290,6 +291,13 @@ export default function AuthenticationSection() { > + {t("policies")}} + > + + diff --git a/src/authentication/EmptyExecutionState.tsx b/src/authentication/EmptyExecutionState.tsx index 36acfa4728..919c76c20c 100644 --- a/src/authentication/EmptyExecutionState.tsx +++ b/src/authentication/EmptyExecutionState.tsx @@ -74,7 +74,7 @@ export const EmptyExecutionState = ({ + + + + + + + )} + {!rows.length && !realm.passwordPolicy && ( + + + + {t("noPasswordPolicies")} + + {t("noPasswordPoliciesInstructions")} + + + + + )} + + ); +}; diff --git a/src/authentication/policies/Policies.tsx b/src/authentication/policies/Policies.tsx new file mode 100644 index 0000000000..1c4fb3cee4 --- /dev/null +++ b/src/authentication/policies/Policies.tsx @@ -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 ( + setSubTab(key as number)} + mountOnEnter + > + {t("passwordPolicy")}} + > + + + {t("otpPolicy")}} + > + {t("webauthnPolicy")}} + > + {t("webauthnPasswordlessPolicy")}} + > + + ); +}; diff --git a/src/authentication/policies/PolicyRow.tsx b/src/authentication/policies/PolicyRow.tsx new file mode 100644 index 0000000000..62e08c7b3b --- /dev/null +++ b/src/authentication/policies/PolicyRow.tsx @@ -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 ( + + + + {configType && configType !== "int" && ( + + )} + {configType === "int" && ( + { + const MIN_VALUE = 0; + const setValue = (newValue: number) => + onChange(Math.max(newValue, MIN_VALUE)); + + return ( + 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 && ( + + )} + + + + + + + ); +}; diff --git a/src/authentication/policies/policy-row.css b/src/authentication/policies/policy-row.css new file mode 100644 index 0000000000..9e8417ee9f --- /dev/null +++ b/src/authentication/policies/policy-row.css @@ -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; +} \ No newline at end of file diff --git a/src/authentication/policies/util.test.ts b/src/authentication/policies/util.test.ts new file mode 100644 index 0000000000..b9ee75e82c --- /dev/null +++ b/src/authentication/policies/util.test.ts @@ -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" }, + ]); + }); +}); diff --git a/src/authentication/policies/util.ts b/src/authentication/policies/util.ts new file mode 100644 index 0000000000..acb6def9c1 --- /dev/null +++ b/src/authentication/policies/util.ts @@ -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((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 }; +} diff --git a/src/common-messages.ts b/src/common-messages.ts index 8374ef93ae..9b89b1b19a 100644 --- a/src/common-messages.ts +++ b/src/common-messages.ts @@ -10,6 +10,7 @@ export default { save: "Save", revert: "Revert", cancel: "Cancel", + reload: "Reload", continue: "Continue", close: "Close", delete: "Delete", diff --git a/src/realm-settings/security-defences/Time.tsx b/src/realm-settings/security-defences/Time.tsx index 1c4b8641d8..f5f2bd7d3d 100644 --- a/src/realm-settings/security-defences/Time.tsx +++ b/src/realm-settings/security-defences/Time.tsx @@ -39,7 +39,7 @@ export const Time = ({ rules={{ required: true }} render={({ onChange, value }) => (