Much better support for frontend field validation

This commit is contained in:
garronej 2021-10-25 21:26:08 +02:00
parent 92fb3b7529
commit 3aad681538
11 changed files with 780 additions and 204 deletions

View File

@ -55,25 +55,25 @@
], ],
"homepage": "https://github.com/garronej/keycloakify", "homepage": "https://github.com/garronej/keycloakify",
"peerDependencies": { "peerDependencies": {
"@emotion/react": "^11.4.1",
"powerhooks": "^0.11.0",
"react": "^16.8.0 || ^17.0.0", "react": "^16.8.0 || ^17.0.0",
"tss-react": "^1.1.0", "tss-react": "^1.1.0"
"powerhooks": "^0.9.6",
"@emotion/react": "^11.4.1"
}, },
"devDependencies": { "devDependencies": {
"tss-react": "^1.1.0",
"@emotion/react": "^11.4.1", "@emotion/react": "^11.4.1",
"powerhooks": "^0.9.6",
"@types/node": "^10.0.0", "@types/node": "^10.0.0",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"powerhooks": "^0.11.0",
"prettier": "^2.3.0",
"properties-parser": "^0.3.1", "properties-parser": "^0.3.1",
"react": "^17.0.1", "react": "^17.0.1",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"typescript": "^4.2.3", "tss-react": "^1.1.0",
"husky": "^4.3.8", "typescript": "^4.2.3"
"lint-staged": "^11.0.0",
"prettier": "^2.3.0"
}, },
"dependencies": { "dependencies": {
"cheerio": "^1.0.0-rc.5", "cheerio": "^1.0.0-rc.5",

View File

@ -3,7 +3,7 @@ import { Template } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useKcMessage } from "../i18n/useKcMessage"; import { useKcMessage } from "../i18n/useKcMessage";
import { appendHead } from "../tools/appendHead"; import { headInsert } from "../tools/headInsert";
import { join as pathJoin } from "path"; import { join as pathJoin } from "path";
import { useCssAndCx } from "tss-react"; import { useCssAndCx } from "tss-react";
@ -17,7 +17,7 @@ export const LoginOtp = memo(({ kcContext, ...props }: { kcContext: KcContextBas
useEffect(() => { useEffect(() => {
let isCleanedUp = false; let isCleanedUp = false;
appendHead({ headInsert({
"type": "javascript", "type": "javascript",
"src": pathJoin(kcContext.url.resourcesCommonPath, "node_modules/jquery/dist/jquery.min.js"), "src": pathJoin(kcContext.url.resourcesCommonPath, "node_modules/jquery/dist/jquery.min.js"),
}).then(() => { }).then(() => {

View File

@ -1,17 +1,29 @@
import { memo, Fragment } from "react"; import { useMemo, memo, useEffect, useState, Fragment } from "react";
import { Template } from "./Template"; import { Template } from "./Template";
import type { KcProps } from "./KcProps"; import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase"; import type { KcContextBase, Attribute } from "../getKcContext/KcContextBase";
import { useKcMessage } from "../i18n/useKcMessage"; import { useKcMessage } from "../i18n/useKcMessage";
import { useCssAndCx } from "tss-react"; import { useCssAndCx } from "tss-react";
import type { ReactComponent } from "../tools/ReactComponent"; import type { ReactComponent } from "../tools/ReactComponent";
import { useCallbackFactory } from "powerhooks/useCallbackFactory";
import { useFormValidationSlice } from "../useFormValidationSlice";
export const RegisterUserProfile = memo(({ kcContext, ...props }: { kcContext: KcContextBase.RegisterUserProfile } & KcProps) => { export const RegisterUserProfile = memo(({ kcContext, ...props_ }: { kcContext: KcContextBase.RegisterUserProfile } & KcProps) => {
const { url, messagesPerField, realm, passwordRequired, recaptchaRequired, recaptchaSiteKey } = kcContext; const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext;
const { msg, msgStr } = useKcMessage(); const { msg, msgStr } = useKcMessage();
const { cx } = useCssAndCx(); const { cx, css } = useCssAndCx();
const props = useMemo(
() => ({
...props_,
"kcFormGroupClass": cx(props_.kcFormGroupClass, css({ "marginBottom": 20 })),
}),
[cx, css],
);
const [isFomSubmittable, setIsFomSubmittable] = useState(false);
return ( return (
<Template <Template
@ -22,71 +34,7 @@ export const RegisterUserProfile = memo(({ kcContext, ...props }: { kcContext: K
headerNode={msg("registerTitle")} headerNode={msg("registerTitle")}
formNode={ formNode={
<form id="kc-register-form" className={cx(props.kcFormClass)} action={url.registrationAction} method="post"> <form id="kc-register-form" className={cx(props.kcFormClass)} action={url.registrationAction} method="post">
<UserProfileFormFields <UserProfileFormFields kcContext={kcContext} onIsFormSubmittableValueChange={setIsFomSubmittable} {...props} />
kcContext={kcContext}
{...props}
AfterField={({ attribute }) =>
/*render password fields just under the username or email (if used as username)*/
(passwordRequired &&
(attribute.name == "username" || (attribute.name == "email" && realm.registrationEmailAsUsername)) && (
<>
<div className={cx(props.kcFormGroupClass)}>
<div className={cx(props.kcLabelWrapperClass)}>
<label htmlFor="password" className={cx(props.kcLabelClass)}>
{msg("password")}
</label>{" "}
*
</div>
<div className={cx(props.kcInputWrapperClass)}>
<input
type="password"
id="password"
className={cx(props.kcInputClass)}
name="password"
autoComplete="new-password"
aria-invalid={
messagesPerField.existsError("password") || messagesPerField.existsError("password-confirm")
}
/>
{messagesPerField.existsError("password") && (
<span id="input-error-password" className={cx(props.kcInputErrorMessageClass)} aria-live="polite">
{messagesPerField.get("password")}
</span>
)}
</div>
</div>
<div className={cx(props.kcFormGroupClass)}>
<div className={cx(props.kcLabelWrapperClass)}>
<label htmlFor="password-confirm" className={cx(props.kcLabelClass)}>
{msg("passwordConfirm")}
</label>{" "}
*
</div>
<div className={cx(props.kcInputWrapperClass)}>
<input
type="password"
id="password-confirm"
className={cx(props.kcInputClass)}
name="password-confirm"
autoComplete="new-password"
aria-invalid={messagesPerField.existsError("password-confirm")}
/>
{messagesPerField.existsError("password-confirm") && (
<span
id="input-error-password-confirm"
className={cx(props.kcInputErrorMessageClass)}
aria-live="polite"
>
{messagesPerField.get("password-confirm")}
</span>
)}
</div>
</div>
</>
)) ||
null
}
/>
{recaptchaRequired && ( {recaptchaRequired && (
<div className="form-group"> <div className="form-group">
<div className={cx(props.kcInputWrapperClass)}> <div className={cx(props.kcInputWrapperClass)}>
@ -108,6 +56,7 @@ export const RegisterUserProfile = memo(({ kcContext, ...props }: { kcContext: K
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonBlockClass, props.kcButtonLargeClass)} className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonBlockClass, props.kcButtonLargeClass)}
type="submit" type="submit"
value={msgStr("doRegister")} value={msgStr("doRegister")}
disabled={!isFomSubmittable}
/> />
</div> </div>
</div> </div>
@ -117,53 +66,81 @@ export const RegisterUserProfile = memo(({ kcContext, ...props }: { kcContext: K
); );
}); });
const UserProfileFormFields = memo( type UserProfileFormFieldsProps = { kcContext: KcContextBase.RegisterUserProfile } & KcProps &
({ Partial<Record<"BeforeField" | "AfterField", ReactComponent<{ attribute: Attribute }>>> & {
kcContext, onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
BeforeField = () => null, };
AfterField = () => null,
...props
}: { kcContext: KcContextBase.RegisterUserProfile } & KcProps &
Partial<
Record<
"BeforeField" | "AfterField",
ReactComponent<{
attribute: KcContextBase.RegisterUserProfile["profile"]["attributes"][number];
}>
>
>) => {
const { messagesPerField } = kcContext;
const { cx } = useCssAndCx(); const UserProfileFormFields = memo(({ kcContext, onIsFormSubmittableValueChange, ...props }: UserProfileFormFieldsProps) => {
const { cx, css } = useCssAndCx();
const { advancedMsg } = useKcMessage(); const { advancedMsg } = useKcMessage();
const {
formValidationState: { fieldStateByAttributeName, isFormSubmittable },
formValidationReducer,
attributesWithPassword,
} = useFormValidationSlice({
kcContext,
});
useEffect(() => {
onIsFormSubmittableValueChange(isFormSubmittable);
}, [isFormSubmittable]);
const onChangeFactory = useCallbackFactory(
(
[name]: [string],
[
{
target: { value },
},
]: [React.ChangeEvent<HTMLInputElement>],
) =>
formValidationReducer({
"action": "update value",
name,
"newValue": value,
}),
);
const onBlurFactory = useCallbackFactory(([name]: [string]) =>
formValidationReducer({
"action": "focus lost",
name,
}),
);
let currentGroup = ""; let currentGroup = "";
return ( return (
<> <>
{kcContext.profile.attributes {attributesWithPassword.map((attribute, i) => {
.map(attribute => [attribute, attribute]) const { group = "", groupDisplayHeader = "", groupDisplayDescription = "" } = attribute;
.map(([attribute, { group = "", groupDisplayHeader = "", groupDisplayDescription = "" }], i) => (
const { value, displayableErrors } = fieldStateByAttributeName[attribute.name];
const formGroupClassName = cx(props.kcFormGroupClass, displayableErrors.length !== 0 && props.kcFormGroupErrorClass);
return (
<Fragment key={i}> <Fragment key={i}>
{group !== currentGroup && (currentGroup = group) !== "" && ( {group !== currentGroup && (currentGroup = group) !== "" && (
<div className={cx(props.kcFormGroupClass)}> <div className={formGroupClassName}>
<div className={cx(props.kcContentWrapperClass)}> <div className={cx(props.kcContentWrapperClass)}>
<label id={`header-${group}`} className={cx(props.kcFormGroupHeader)}> <label id={`header-${group}`} className={cx(props.kcFormGroupHeader)}>
{(groupDisplayHeader !== "" && advancedMsg(groupDisplayHeader)) || currentGroup} {advancedMsg(groupDisplayHeader) || currentGroup}
</label> </label>
</div> </div>
{groupDisplayDescription !== "" && ( {groupDisplayDescription !== "" && (
<div className={cx(props.kcLabelWrapperClass)}> <div className={cx(props.kcLabelWrapperClass)}>
<label id={`description-${group}`} className={`${cx(props.kcLabelClass)}`}> <label id={`description-${group}`} className={`${cx(props.kcLabelClass)}`}>
{advancedMsg(groupDisplayDescription) ?? ""} {advancedMsg(groupDisplayDescription)}
</label> </label>
</div> </div>
)} )}
</div> </div>
)} )}
<BeforeField attribute={attribute} /> <div className={formGroupClassName}>
<div className={cx(props.kcFormGroupClass)}>
<div className={cx(props.kcLabelWrapperClass)}> <div className={cx(props.kcLabelWrapperClass)}>
<label htmlFor={attribute.name} className={cx(props.kcLabelClass)}> <label htmlFor={attribute.name} className={cx(props.kcLabelClass)}>
{advancedMsg(attribute.displayName ?? "")} {advancedMsg(attribute.displayName ?? "")}
@ -172,30 +149,59 @@ const UserProfileFormFields = memo(
</div> </div>
<div className={cx(props.kcInputWrapperClass)}> <div className={cx(props.kcInputWrapperClass)}>
<input <input
type="text" autoComplete={(() => {
switch (attribute.name) {
case "password-confirm":
case "password":
return "new-password";
default:
return undefined;
}
})()}
type={(() => {
switch (attribute.name) {
case "password-confirm":
return "password-confirm";
case "password":
return "password";
default:
return "text";
}
})()}
id={attribute.name} id={attribute.name}
name={attribute.name} name={attribute.name}
defaultValue={attribute.value ?? ""} value={value}
onChange={onChangeFactory(attribute.name)}
className={cx(props.kcInputClass)} className={cx(props.kcInputClass)}
aria-invalid={messagesPerField.existsError(attribute.name)} aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly} disabled={attribute.readOnly}
{...(attribute.autocomplete === undefined {...(attribute.autocomplete === undefined
? {} ? {}
: { : {
"autoComplete": attribute.autocomplete, "autoComplete": attribute.autocomplete,
})} })}
onBlur={onBlurFactory(attribute.name)}
/> />
{kcContext.messagesPerField.existsError(attribute.name) && ( {displayableErrors.length !== 0 && (
<span id={`input-error-${attribute.name}`} className={cx(props.kcInputErrorMessageClass)} aria-live="polite"> <span
{messagesPerField.get(attribute.name)} id={`input-error-${attribute.name}`}
className={cx(
props.kcInputErrorMessageClass,
css({
"position": displayableErrors.length === 1 ? "absolute" : undefined,
"& > span": { "display": "block" },
}),
)}
aria-live="polite"
>
{displayableErrors.map(({ errorMessage }) => errorMessage)}
</span> </span>
)} )}
</div> </div>
</div> </div>
<AfterField attribute={attribute} />
</Fragment> </Fragment>
))} );
})}
</> </>
); );
}, });
);

View File

@ -243,6 +243,12 @@ export type Validators = Partial<{
"person-name-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage; "person-name-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage;
uri: Validators.DoIgnoreEmpty; uri: Validators.DoIgnoreEmpty;
"username-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage; "username-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage;
/** Made up validator that only exists in Keycloakify */
_compareToOther: Validators.DoIgnoreEmpty &
Validators.ErrorMessage & {
name: string;
shouldBe: "equal" | "different";
};
}>; }>;
export declare namespace Validators { export declare namespace Validators {
@ -256,8 +262,8 @@ export declare namespace Validators {
export type Range = { export type Range = {
/** "0", "1", "2"... yeah I know, don't tell me */ /** "0", "1", "2"... yeah I know, don't tell me */
min?: string; min?: `${number}`;
max?: string; max?: `${number}`;
}; };
} }

View File

@ -210,6 +210,7 @@ export const kcContextMocks: KcContextBase[] = [
"autocomplete": "username", "autocomplete": "username",
"readOnly": false, "readOnly": false,
"name": "username", "name": "username",
"value": "xxxx",
}, },
{ {
"validators": { "validators": {
@ -226,6 +227,10 @@ export const kcContextMocks: KcContextBase[] = [
"email": { "email": {
"ignore.empty.value": true, "ignore.empty.value": true,
}, },
"pattern": {
"ignore.empty.value": true,
"pattern": "gmail\\.com$",
},
}, },
"displayName": "${email}", "displayName": "${email}",
"annotations": {}, "annotations": {},

View File

@ -1,7 +1,27 @@
import { kcMessages } from "../generated_kcMessages/15.0.2/login"; import { kcMessages as kcMessagesBase } from "../generated_kcMessages/15.0.2/login";
import { Evt } from "evt"; import { Evt } from "evt";
import { objectKeys } from "tsafe/objectKeys"; import { objectKeys } from "tsafe/objectKeys";
const kcMessages = {
...kcMessagesBase,
"en": {
...kcMessagesBase["en"],
"shouldBeEqual": "{0} should be equal to {1}",
"shouldBeDifferent": "{0} should be different to {1}",
"shouldMatchPattern": "Pattern should match: `/{0}/`",
"mustBeAnInteger": "Must be an integer",
},
"fr": {
...kcMessagesBase["fr"],
/* spell-checker: disable */
"shouldBeEqual": "{0} doit être egale à {1}",
"shouldBeDifferent": "{0} doit être différent de {1}",
"shouldMatchPattern": "Dois respecter le schéma: `/{0}/`",
"mustBeAnInteger": "Doit être un nombre entiers",
/* spell-checker: enable */
},
};
export const evtTermsUpdated = Evt.asNonPostable(Evt.create<void>()); export const evtTermsUpdated = Evt.asNonPostable(Evt.create<void>());
(["termsText", "doAccept", "doDecline", "termsTitle"] as const).forEach(key => (["termsText", "doAccept", "doDecline", "termsTitle"] as const).forEach(key =>

View File

@ -1,21 +1,95 @@
import { useCallback, useReducer } from "react"; import "minimal-polyfills/Object.fromEntries";
import { useReducer } from "react";
import { useKcLanguageTag } from "./useKcLanguageTag"; import { useKcLanguageTag } from "./useKcLanguageTag";
import { kcMessages, evtTermsUpdated } from "./kcMessages/login"; import { kcMessages, evtTermsUpdated } from "./kcMessages/login";
import { useEvt } from "evt/hooks"; import { useEvt } from "evt/hooks";
//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35 //NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { id } from "tsafe/id"; import { useGuaranteedMemo } from "powerhooks/useGuaranteedMemo";
export { kcMessages }; export { kcMessages };
export type MessageKey = keyof typeof kcMessages["en"]; export type MessageKey = keyof typeof kcMessages["en"];
function resolveMsg<Key extends string, DoRenderMarkdown extends boolean>(props: {
key: Key;
args: (string | undefined)[];
kcLanguageTag: string;
doRenderMarkdown: DoRenderMarkdown;
}): Key extends MessageKey ? (DoRenderMarkdown extends true ? JSX.Element : string) : undefined {
const { key, args, kcLanguageTag, doRenderMarkdown } = props;
let str = kcMessages[kcLanguageTag as any as "en"][key as MessageKey] ?? kcMessages["en"][key as MessageKey];
if (str === undefined) {
return undefined as any;
}
str = (() => {
const startIndex = str
.match(/(?<={)[0-9]+(?=})/g)
?.map(g => parseInt(g))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
return str;
}
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
str = str.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg);
});
return str;
})();
return (
doRenderMarkdown ? (
<ReactMarkdown allowDangerousHtml renderers={key === "termsText" ? undefined : { "paragraph": "span" }}>
{str}
</ReactMarkdown>
) : (
str
)
) as any;
}
function resolveMsgAdvanced<Key extends string, DoRenderMarkdown extends boolean>(props: {
key: Key;
args: (string | undefined)[];
kcLanguageTag: string;
doRenderMarkdown: DoRenderMarkdown;
}): DoRenderMarkdown extends true ? JSX.Element : string {
const { key, args, kcLanguageTag, doRenderMarkdown } = props;
const match = key.match(/^\$\{([^{]+)\}$/);
const resolvedKey = match === null ? key : match[1];
const out = resolveMsg({
"key": resolvedKey,
args,
kcLanguageTag,
doRenderMarkdown,
});
return (out !== undefined ? out : match === null ? doRenderMarkdown ? <span>{key}</span> : key : undefined) as any;
}
/** /**
* When the language is switched the page is reloaded, this may appear * When the language is switched the page is reloaded, this may appear
* as a bug as you might notice that the language successfully switch before * as a bug as you might notice that the language successfully switch before
* reload. * reload.
* However we need to tell Keycloak that the user have changed the language * However we need to tell Keycloak that the user have changed the language
* during login so we can retrieve the "local" field of the JWT encoded accessToken. * during login so we can retrieve the "local" field of the JWT encoded accessToken.
* https://user-images.githubusercontent.com/6702424/138096682-351bb61f-f24e-4caf-91b7-cca8cfa2cb58.mov
*
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied")
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key"
*
*/ */
export function useKcMessage() { export function useKcMessage() {
const { kcLanguageTag } = useKcLanguageTag(); const { kcLanguageTag } = useKcLanguageTag();
@ -24,46 +98,17 @@ export function useKcMessage() {
useEvt(ctx => evtTermsUpdated.attach(ctx, forceUpdate), []); useEvt(ctx => evtTermsUpdated.attach(ctx, forceUpdate), []);
const msgStr = useCallback( return useGuaranteedMemo(
(key: MessageKey, ...args: (string | undefined)[]): string => { () => ({
let str: string = kcMessages[kcLanguageTag as any as "en"][key] ?? kcMessages["en"][key]; "msgStr": (key: MessageKey, ...args: (string | undefined)[]): string =>
resolveMsg({ key, args, kcLanguageTag, "doRenderMarkdown": false }),
args.forEach((arg, i) => { "msg": (key: MessageKey, ...args: (string | undefined)[]): JSX.Element =>
if (arg === undefined) { resolveMsg({ key, args, kcLanguageTag, "doRenderMarkdown": true }),
return; "advancedMsg": <Key extends string>(key: Key, ...args: (string | undefined)[]): JSX.Element =>
} resolveMsgAdvanced({ key, args, kcLanguageTag, "doRenderMarkdown": true }),
"advancedMsgStr": <Key extends string>(key: Key, ...args: (string | undefined)[]): string =>
str = str.replace(new RegExp(`\\{${i}\\}`, "g"), arg); resolveMsgAdvanced({ key, args, kcLanguageTag, "doRenderMarkdown": false }),
}); }),
return str;
},
[kcLanguageTag, trigger], [kcLanguageTag, trigger],
); );
const msg = useCallback<(...args: Parameters<typeof msgStr>) => JSX.Element>(
(key, ...args) => (
<ReactMarkdown allowDangerousHtml renderers={key === "termsText" ? undefined : { "paragraph": "span" }}>
{msgStr(key, ...args)}
</ReactMarkdown>
),
[msgStr],
);
const advancedMsg = useCallback(
(key: string): string | undefined => {
const match = key.match(/^\$\{([^{]+)\}$/);
const resolvedKey = match === null ? key : match[1];
const out =
id<Record<string, string | undefined>>(kcMessages[kcLanguageTag])[resolvedKey] ??
id<Record<string, string | undefined>>(kcMessages["en"])[resolvedKey];
return out !== undefined ? out : match === null ? key : undefined;
},
[msgStr],
);
return { msg, msgStr, advancedMsg };
} }

View File

@ -0,0 +1,64 @@
if (!Array.prototype.every) {
Array.prototype.every = function (callbackfn: any, thisArg: any) {
"use strict";
var T, k;
if (this == null) {
throw new TypeError("this is null or not defined");
}
// 1. Let O be the result of calling ToObject passing the this
// value as the argument.
var O = Object(this);
// 2. Let lenValue be the result of calling the Get internal method
// of O with the argument "length".
// 3. Let len be ToUint32(lenValue).
var len = O.length >>> 0;
// 4. If IsCallable(callbackfn) is false, throw a TypeError exception.
if (typeof callbackfn !== "function" && Object.prototype.toString.call(callbackfn) !== "[object Function]") {
throw new TypeError();
}
// 5. If thisArg was supplied, let T be thisArg; else let T be undefined.
if (arguments.length > 1) {
T = thisArg;
}
// 6. Let k be 0.
k = 0;
// 7. Repeat, while k < len
while (k < len) {
var kValue;
// a. Let Pk be ToString(k).
// This is implicit for LHS operands of the in operator
// b. Let kPresent be the result of calling the HasProperty internal
// method of O with argument Pk.
// This step can be combined with c
// c. If kPresent is true, then
if (k in O) {
var testResult;
// i. Let kValue be the result of calling the Get internal method
// of O with argument Pk.
kValue = O[k];
// ii. Let testResult be the result of calling the Call internal method
// of callbackfn with T as the this value if T is not undefined
// else is the result of calling callbackfn
// and argument list containing kValue, k, and O.
if (T) testResult = callbackfn.call(T, kValue, k, O);
else testResult = callbackfn(kValue, k, O);
// iii. If ToBoolean(testResult) is false, return false.
if (!testResult) {
return false;
}
}
k++;
}
return true;
};
}

View File

@ -0,0 +1,2 @@
export const emailRegexp =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

View File

@ -0,0 +1,428 @@
import "./tools/Array.prototype.every";
import { useMemo, useReducer, Fragment } from "react";
import type { KcContextBase, Validators, Attribute } from "./getKcContext/KcContextBase";
import { useKcMessage } from "./i18n/useKcMessage";
import { useConstCallback } from "powerhooks/useConstCallback";
import { id } from "tsafe/id";
import type { MessageKey } from "./i18n/useKcMessage";
import { useConst } from "powerhooks/useConst";
import { emailRegexp } from "./tools/emailRegExp";
export type KcContextLike = {
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
attributes: { name: string; value?: string; validators: Validators }[];
passwordRequired: boolean;
realm: { registrationEmailAsUsername: boolean };
};
export function useGetErrors(params: {
kcContext: {
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: { name: string; value?: string; validators: Validators }[];
};
};
}) {
const {
kcContext: {
messagesPerField,
profile: { attributes },
},
} = params;
const { msg, msgStr, advancedMsg, advancedMsgStr } = useKcMessage();
const getErrors = useConstCallback((params: { name: string; fieldValueByAttributeName: Record<string, { value: string }> }) => {
const { name, fieldValueByAttributeName } = params;
const { value } = fieldValueByAttributeName[name];
const { value: defaultValue, validators } = attributes.find(attribute => attribute.name === name)!;
if (defaultValue === value && messagesPerField.existsError(value)) {
const errorMessageStr = messagesPerField.get(value);
return [
{
"validatorName": undefined,
errorMessageStr,
"errorMessage": <span key={0}>{errorMessageStr}</span>,
},
];
}
const errors: {
errorMessage: JSX.Element;
errorMessageStr: string;
validatorName: keyof Validators | undefined;
}[] = [];
scope: {
const validatorName = "length";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
if (max !== undefined && value.length > parseInt(max)) {
const msgArgs = ["error-invalid-length-too-long", max] as const;
errors.push({
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
validatorName,
});
}
if (min !== undefined && value.length < parseInt(min)) {
const msgArgs = ["error-invalid-length-too-short", min] as const;
errors.push({
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
validatorName,
});
}
}
scope: {
const validatorName = "_compareToOther";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false, name: otherName, shouldBe, "error-message": errorMessageKey } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
const { value: otherValue } = fieldValueByAttributeName[otherName];
const isValid = (() => {
switch (shouldBe) {
case "different":
return otherValue !== value;
case "equal":
return otherValue === value;
}
})();
if (isValid) {
break scope;
}
const msgArg = [
errorMessageKey ??
id<MessageKey>(
(() => {
switch (shouldBe) {
case "equal":
return "shouldBeEqual";
case "different":
return "shouldBeDifferent";
}
})(),
),
otherName,
name,
shouldBe,
] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArg)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArg),
});
}
scope: {
const validatorName = "pattern";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
if (new RegExp(pattern).test(value)) {
break scope;
}
const msgArgs = [errorMessageKey ?? id<MessageKey>("shouldMatchPattern"), pattern] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArgs),
});
}
scope: {
if ([...errors].reverse()[0]?.validatorName === "pattern") {
break scope;
}
const validatorName = "email";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
if (emailRegexp.test(value)) {
break scope;
}
const msgArgs = ["invalidEmailMessage"] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
});
}
scope: {
const validatorName = "integer";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator;
if (ignoreEmptyValue && value === "") {
break scope;
}
const intValue = parseInt(value);
if (isNaN(intValue)) {
const msgArgs = ["mustBeAnInteger"] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
});
break scope;
}
if (max !== undefined && intValue > parseInt(max)) {
const msgArgs = ["error-number-out-of-range-too-big", max] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
});
break scope;
}
if (min !== undefined && intValue < parseInt(min)) {
const msgArgs = ["error-number-out-of-range-too-small", min] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
});
break scope;
}
}
//TODO: Implement missing validators.
return errors;
});
return { getErrors };
}
export function useFormValidationSlice(params: {
kcContext: {
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: Attribute[];
};
passwordRequired: boolean;
realm: { registrationEmailAsUsername: boolean };
};
passwordValidators?: Validators;
}) {
const {
kcContext,
passwordValidators = {
"length": {
"ignore.empty.value": true,
"min": "4",
},
},
} = params;
const attributesWithPassword = useConst(() =>
!kcContext.passwordRequired
? kcContext.profile.attributes
: (() => {
const name = kcContext.realm.registrationEmailAsUsername ? "email" : "username";
return kcContext.profile.attributes.reduce<Attribute[]>(
(prev, curr) => [
...prev,
...(curr.name !== name
? [curr]
: [
curr,
id<Attribute>({
"name": "password",
"displayName": id<`\${${MessageKey}}`>("${password}"),
"required": true,
"readOnly": false,
"validators": passwordValidators,
"annotations": {},
"groupAnnotations": {},
}),
id<Attribute>({
"name": "password-confirm",
"displayName": id<`\${${MessageKey}}`>("${passwordConfirm}"),
"required": true,
"readOnly": false,
"validators": {
"_compareToOther": {
"name": "password",
"ignore.empty.value": true,
"shouldBe": "equal",
"error-message": id<`\${${MessageKey}}`>("${invalidPasswordConfirmMessage}"),
},
},
"annotations": {},
"groupAnnotations": {},
}),
]),
],
[],
);
})(),
);
const { getErrors } = useGetErrors({
"kcContext": {
"messagesPerField": kcContext.messagesPerField,
"profile": {
"attributes": attributesWithPassword,
},
},
});
const initialInternalState = useConst(() =>
Object.fromEntries(
attributesWithPassword
.map(attribute => ({
attribute,
"errors": getErrors({
"name": attribute.name,
"fieldValueByAttributeName": Object.fromEntries(
attributesWithPassword.map(({ name, value }) => [name, { "value": value ?? "" }]),
),
}),
}))
.map(({ attribute, errors }) => [
attribute.name,
{
"value": attribute.value ?? "",
errors,
"doDisplayPotentialErrorMessages": errors.length !== 0,
},
]),
),
);
type InternalState = typeof initialInternalState;
const [formValidationInternalState, formValidationReducer] = useReducer(
(
state: InternalState,
params:
| {
action: "update value";
name: string;
newValue: string;
}
| {
action: "focus lost";
name: string;
},
): InternalState => ({
...state,
[params.name]: {
...state[params.name],
...(() => {
switch (params.action) {
case "focus lost":
return { "doDisplayPotentialErrorMessages": true };
case "update value":
return {
"value": params.newValue,
"errors": getErrors({
"name": params.name,
"fieldValueByAttributeName": {
...state,
[params.name]: { "value": params.newValue },
},
}),
};
}
})(),
},
}),
initialInternalState,
);
const formValidationState = useMemo(
() => ({
"fieldStateByAttributeName": Object.fromEntries(
Object.entries(formValidationInternalState).map(([name, { value, errors, doDisplayPotentialErrorMessages }]) => [
name,
{ value, "displayableErrors": doDisplayPotentialErrorMessages ? errors : [] },
]),
),
"isFormSubmittable": Object.entries(formValidationInternalState).every(
([name, { value, errors }]) =>
errors.length === 0 && (value !== "" || !attributesWithPassword.find(attribute => attribute.name === name)!.required),
),
}),
[formValidationInternalState],
);
return { formValidationState, formValidationReducer, attributesWithPassword };
}

View File

@ -1221,10 +1221,10 @@ please-upgrade-node@^3.2.0:
dependencies: dependencies:
semver-compare "^1.0.0" semver-compare "^1.0.0"
powerhooks@^0.9.6: powerhooks@^0.11.0:
version "0.9.6" version "0.11.0"
resolved "https://registry.yarnpkg.com/powerhooks/-/powerhooks-0.9.6.tgz#45bdd7e7713d0a620b1b099cf2685e5f56cebd8f" resolved "https://registry.yarnpkg.com/powerhooks/-/powerhooks-0.11.0.tgz#923749ed405a1189759cd3f16d36bd5efacfd40c"
integrity sha512-vXGcC5Ty3e5wxnRP37c7rnTE/UY86VVLwAj3tqAMvC9xf1C9wOmu2Q7xTj/4FGK1oGvgqbTiiWuxd+WK4C7kEQ== integrity sha512-I48J5vJqlTRiR3eH6svxiIYLutdedu2YEd3uhCmK+pRg2jcuourVwp1UYORI/EzbKC9vv0X/0Vd/SZ6e07rYtA==
dependencies: dependencies:
evt "2.0.0-beta.38" evt "2.0.0-beta.38"
memoizee "^0.4.15" memoizee "^0.4.15"