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 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 { 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() {
|
|||
>
|
||||
<RequiredActions />
|
||||
</Tab>
|
||||
<Tab
|
||||
id="policies"
|
||||
eventKey="policies"
|
||||
title={<TabTitleText>{t("policies")}</TabTitleText>}
|
||||
>
|
||||
<Policies />
|
||||
</Tab>
|
||||
</KeycloakTabs>
|
||||
</PageSection>
|
||||
</>
|
||||
|
|
|
@ -74,7 +74,7 @@ export const EmptyExecutionState = ({
|
|||
<Flex alignSelf={{ default: "alignSelfCenter" }}>
|
||||
<FlexItem>
|
||||
<Button
|
||||
data-testId={section}
|
||||
data-testid={section}
|
||||
variant="tertiary"
|
||||
onClick={() => setShow(section)}
|
||||
>
|
||||
|
|
|
@ -3,6 +3,18 @@ export default {
|
|||
title: "Authentication",
|
||||
flows: "Flows",
|
||||
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",
|
||||
searchForFlow: "Search for flow",
|
||||
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",
|
||||
revert: "Revert",
|
||||
cancel: "Cancel",
|
||||
reload: "Reload",
|
||||
continue: "Continue",
|
||||
close: "Close",
|
||||
delete: "Delete",
|
||||
|
|
|
@ -39,7 +39,7 @@ export const Time = ({
|
|||
rules={{ required: true }}
|
||||
render={({ onChange, value }) => (
|
||||
<TimeSelector
|
||||
data-testId={name}
|
||||
data-testid={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
validated={
|
||||
|
|
Loading…
Reference in a new issue