Compare commits

..

17 Commits

28 changed files with 745 additions and 265 deletions

View File

@ -8,9 +8,6 @@
<a href="https://github.com/garronej/keycloakify/actions">
<img src="https://github.com/garronej/keycloakify/workflows/ci/badge.svg?branch=main">
</a>
<a href="https://bundlephobia.com/package/keycloakify">
<img src="https://img.shields.io/bundlephobia/minzip/keycloakify">
</a>
<a href="https://www.npmjs.com/package/keycloakify">
<img src="https://img.shields.io/npm/dm/keycloakify">
</a>

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "6.12.1",
"version": "6.12.8",
"description": "Keycloak theme generator for Reacts app",
"repository": {
"type": "git",
@ -81,7 +81,7 @@
"@octokit/rest": "^18.12.0",
"cheerio": "^1.0.0-rc.5",
"cli-select": "^1.1.2",
"evt": "^2.4.13",
"evt": "^2.4.15",
"minimal-polyfills": "^2.2.2",
"minimist": "^1.2.6",
"path-browserify": "^1.0.1",

View File

@ -174,6 +174,10 @@ ${ftl_object_to_js_code_declaring_an_object(.data_model, [])?no_esc};
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
are_same_path(path, ["realm"])
) || (
"error.ftl" == pageId &&
are_same_path(path, ["realm"]) &&
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
)
>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>

View File

@ -1,9 +1,9 @@
import React from "react";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function Error(props: PageProps<KcContextBase.Error, I18nBase>) {
export default function Error(props: PageProps<Extract<KcContextBase, { pageId: "error.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { message, client } = kcContext;

View File

@ -1,11 +1,11 @@
import React, { useState } from "react";
import { clsx } from "../tools/clsx";
import { UserProfileFormFields } from "./shared/UserProfileCommons";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function IdpReviewUserProfile(props: PageProps<KcContextBase.IdpReviewUserProfile, I18nBase>) {
export default function IdpReviewUserProfile(props: PageProps<Extract<KcContextBase, { pageId: "idp-review-user-profile.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { msg, msgStr } = i18n;

View File

@ -1,10 +1,10 @@
import React from "react";
import { assert } from "../tools/assert";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function Info(props: PageProps<KcContextBase.Info, I18nBase>) {
export default function Info(props: PageProps<Extract<KcContextBase, { pageId: "info.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { msgStr, msg } = i18n;

View File

@ -1,11 +1,11 @@
import React, { useState, type FormEventHandler } from "react";
import { clsx } from "../tools/clsx";
import { useConstCallback } from "../tools/useConstCallback";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function Login(props: PageProps<KcContextBase.Login, I18nBase>) {
export default function Login(props: PageProps<Extract<KcContextBase, { pageId: "login.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { social, realm, url, usernameEditDisabled, login, auth, registrationDisabled } = kcContext;

View File

@ -1,10 +1,10 @@
import React from "react";
import { clsx } from "../tools/clsx";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginConfigTotp(props: PageProps<KcContextBase.LoginConfigTotp, I18nBase>) {
export default function LoginConfigTotp(props: PageProps<Extract<KcContextBase, { pageId: "login-config-totp.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { url, isAppInitiatedAction, totp, mode, messagesPerField } = kcContext;

View File

@ -1,10 +1,10 @@
import React from "react";
import { clsx } from "../tools/clsx";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginIdpLinkConfirm(props: PageProps<KcContextBase.LoginIdpLinkConfirm, I18nBase>) {
export default function LoginIdpLinkConfirm(props: PageProps<Extract<KcContextBase, { pageId: "login-idp-link-confirm.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { url, idpAlias } = kcContext;

View File

@ -3,7 +3,7 @@ import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { I18nBase } from "../i18n";
export default function LoginIdpLinkEmail(props: PageProps<KcContextBase.LoginIdpLinkEmail, I18nBase>) {
export default function LoginIdpLinkEmail(props: PageProps<Extract<KcContextBase, { pageId: "login-idp-link-email.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { url, realm, brokerContext, idpAlias } = kcContext;

View File

@ -2,11 +2,11 @@ import React, { useEffect } from "react";
import { headInsert } from "../tools/headInsert";
import { pathJoin } from "../../bin/tools/pathJoin";
import { clsx } from "../tools/clsx";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginOtp(props: PageProps<KcContextBase.LoginOtp, I18nBase>) {
export default function LoginOtp(props: PageProps<Extract<KcContextBase, { pageId: "login-otp.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { otpLogin, url } = kcContext;

View File

@ -1,9 +1,9 @@
import React from "react";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginPageExpired(props: PageProps<KcContextBase.LoginPageExpired, I18nBase>) {
export default function LoginPageExpired(props: PageProps<Extract<KcContextBase, { pageId: "login-page-expired.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { url } = kcContext;

View File

@ -2,11 +2,11 @@ import React, { useState } from "react";
import { clsx } from "../tools/clsx";
import { useConstCallback } from "../tools/useConstCallback";
import type { FormEventHandler } from "react";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginPassword(props: PageProps<KcContextBase.LoginPassword, I18nBase>) {
export default function LoginPassword(props: PageProps<Extract<KcContextBase, { "pageId": "login-password.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { realm, url, login } = kcContext;

View File

@ -1,10 +1,10 @@
import React from "react";
import { clsx } from "../tools/clsx";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginResetPassword(props: PageProps<KcContextBase.LoginResetPassword, I18nBase>) {
export default function LoginResetPassword(props: PageProps<Extract<KcContextBase, { pageId: "login-reset-password.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { url, realm, auth } = kcContext;

View File

@ -1,10 +1,10 @@
import React from "react";
import { clsx } from "../tools/clsx";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginUpdatePassword(props: PageProps<KcContextBase.LoginUpdatePassword, I18nBase>) {
export default function LoginUpdatePassword(props: PageProps<Extract<KcContextBase, { pageId: "login-update-password.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { msg, msgStr } = i18n;

View File

@ -1,10 +1,10 @@
import React from "react";
import { clsx } from "../tools/clsx";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginUpdateProfile(props: PageProps<KcContextBase.LoginUpdateProfile, I18nBase>) {
export default function LoginUpdateProfile(props: PageProps<Extract<KcContextBase, { pageId: "login-update-profile.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { msg, msgStr } = i18n;

View File

@ -2,11 +2,11 @@ import React, { useState } from "react";
import { clsx } from "../tools/clsx";
import { useConstCallback } from "../tools/useConstCallback";
import type { FormEventHandler } from "react";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginUsername(props: PageProps<KcContextBase.LoginUsername, I18nBase>) {
export default function LoginUsername(props: PageProps<Extract<KcContextBase, { pageId: "login-username.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { social, realm, url, usernameHidden, login, registrationDisabled } = kcContext;

View File

@ -1,9 +1,9 @@
import React from "react";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LoginVerifyEmail(props: PageProps<KcContextBase.LoginVerifyEmail, I18nBase>) {
export default function LoginVerifyEmail(props: PageProps<Extract<KcContextBase, { pageId: "login-verify-email.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { msg } = i18n;

View File

@ -1,10 +1,10 @@
import React from "react";
import { clsx } from "../tools/clsx";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function LogoutConfirm(props: PageProps<KcContextBase.LogoutConfirm, I18nBase>) {
export default function LogoutConfirm(props: PageProps<Extract<KcContextBase, { pageId: "logout-confirm.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { url, client, logoutConfirm } = kcContext;

View File

@ -1,10 +1,10 @@
import React from "react";
import { clsx } from "../tools/clsx";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function Register(props: PageProps<KcContextBase.Register, I18nBase>) {
export default function Register(props: PageProps<Extract<KcContextBase, { pageId: "register.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { url, messagesPerField, register, realm, passwordRequired, recaptchaRequired, recaptchaSiteKey } = kcContext;

View File

@ -1,11 +1,11 @@
import React, { useState } from "react";
import { clsx } from "../tools/clsx";
import { UserProfileFormFields } from "./shared/UserProfileCommons";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function RegisterUserProfile(props: PageProps<KcContextBase.RegisterUserProfile, I18nBase>) {
export default function RegisterUserProfile(props: PageProps<Extract<KcContextBase, { pageId: "register-user-profile.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { url, messagesPerField, recaptchaRequired, recaptchaSiteKey } = kcContext;

View File

@ -9,11 +9,11 @@ import { useConst } from "../tools/useConst";
import { useConstCallback } from "../tools/useConstCallback";
import { Markdown } from "../tools/Markdown";
import type { Extends } from "tsafe";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function Terms(props: PageProps<KcContextBase.Terms, I18nBase>) {
export default function Terms(props: PageProps<Extract<KcContextBase, { pageId: "terms.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { msg, msgStr } = i18n;

View File

@ -1,11 +1,11 @@
import React, { useState } from "react";
import { clsx } from "../tools/clsx";
import { UserProfileFormFields } from "./shared/UserProfileCommons";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function UpdateUserProfile(props: PageProps<KcContextBase.UpdateUserProfile, I18nBase>) {
export default function UpdateUserProfile(props: PageProps<Extract<KcContextBase, { pageId: "update-user-profile.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { msg, msgStr } = i18n;

View File

@ -3,11 +3,11 @@ import { clsx } from "../tools/clsx";
import type { MessageKeyBase } from "../i18n";
import { base64url } from "rfc4648";
import { useConstCallback } from "../tools/useConstCallback";
import type { KcContextBase } from "../getKcContext";
import type { PageProps } from "../KcProps";
import type { KcContextBase } from "../getKcContext";
import type { I18nBase } from "../i18n";
export default function WebauthnAuthenticate(props: PageProps<KcContextBase.WebauthnAuthenticate, I18nBase>) {
export default function WebauthnAuthenticate(props: PageProps<Extract<KcContextBase, { pageId: "webauthn-authenticate.ftl" }>, I18nBase>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { url } = kcContext;

View File

@ -1,18 +1,27 @@
import React, { useEffect, Fragment } from "react";
import type { KcProps } from "../../KcProps";
import type { Attribute } from "../../getKcContext/KcContextBase";
import { clsx } from "../../tools/clsx";
import type { ReactComponent } from "../../tools/ReactComponent";
import { useCallbackFactory } from "../../tools/useCallbackFactory";
import { useFormValidationSlice } from "../../useFormValidationSlice";
import type { I18nBase } from "../../i18n";
import type { Param0 } from "tsafe/Param0";
import type { Attribute } from "../../getKcContext";
// If you are copy pasting this code in your theme project
// you can delete all the following import and replace them by
// import { useFormValidation } from "keycloakify/lib/pages/shared/UserProfileCommons";
// you can also delete the useFormValidation hooks and useGetErrors hooks, they shouldn't need
// to be modified.
import "../../tools/Array.prototype.every";
import { useMemo, useReducer } from "react";
import type { KcContextBase, Validators } from "../../getKcContext";
import { useConstCallback } from "../../tools/useConstCallback";
import { emailRegexp } from "../../tools/emailRegExp";
import type { MessageKeyBase } from "../../i18n";
import { id } from "tsafe/id";
export type UserProfileFormFieldsProps = {
kcContext: Param0<typeof useFormValidationSlice>["kcContext"];
kcContext: Parameters<typeof useFormValidation>[0]["kcContext"];
i18n: I18nBase;
} & KcProps &
Partial<Record<"BeforeField" | "AfterField", ReactComponent<{ attribute: Attribute }>>> & {
Partial<Record<"BeforeField" | "AfterField", (props: { attribute: Attribute }) => JSX.Element | null>> & {
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
};
@ -28,9 +37,9 @@ export function UserProfileFormFields({
const {
formValidationState: { fieldStateByAttributeName, isFormSubmittable },
formValidationReducer,
formValidationDispatch,
attributesWithPassword
} = useFormValidationSlice({
} = useFormValidation({
kcContext,
i18n
});
@ -39,29 +48,6 @@ export function UserProfileFormFields({
onIsFormSubmittableValueChange(isFormSubmittable);
}, [isFormSubmittable]);
const onChangeFactory = useCallbackFactory(
(
[name]: [string],
[
{
target: { value }
}
]: [React.ChangeEvent<HTMLInputElement | HTMLSelectElement>]
) =>
formValidationReducer({
"action": "update value",
name,
"newValue": value
})
);
const onBlurFactory = useCallbackFactory(([name]: [string]) =>
formValidationReducer({
"action": "focus lost",
name
})
);
let currentGroup = "";
return (
@ -110,8 +96,19 @@ export function UserProfileFormFields({
<select
id={attribute.name}
name={attribute.name}
onChange={onChangeFactory(attribute.name)}
onBlur={onBlurFactory(attribute.name)}
onChange={event =>
formValidationDispatch({
"action": "update value",
"name": attribute.name,
"newValue": event.target.value
})
}
onBlur={() =>
formValidationDispatch({
"action": "focus lost",
"name": attribute.name
})
}
value={value}
>
{options.options.map(option => (
@ -137,12 +134,23 @@ export function UserProfileFormFields({
id={attribute.name}
name={attribute.name}
value={value}
onChange={onChangeFactory(attribute.name)}
onChange={event =>
formValidationDispatch({
"action": "update value",
"name": attribute.name,
"newValue": event.target.value
})
}
onBlur={() =>
formValidationDispatch({
"action": "focus lost",
"name": attribute.name
})
}
className={clsx(props.kcInputClass)}
aria-invalid={displayableErrors.length !== 0}
disabled={attribute.readOnly}
autoComplete={attribute.autocomplete}
onBlur={onBlurFactory(attribute.name)}
/>
);
})()}
@ -168,7 +176,6 @@ export function UserProfileFormFields({
})()}
</div>
</div>
{AfterField && <AfterField attribute={attribute} />}
</Fragment>
);
@ -176,3 +183,477 @@ export function UserProfileFormFields({
</>
);
}
/**
* NOTE: The attributesWithPassword returned is actually augmented with
* artificial password related attributes only if kcContext.passwordRequired === true
*/
export function useFormValidation(params: {
kcContext: {
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: Attribute[];
};
passwordRequired?: boolean;
realm: { registrationEmailAsUsername: boolean };
};
/** NOTE: Try to avoid passing a new ref every render for better performances. */
passwordValidators?: Validators;
i18n: I18nBase;
}) {
const {
kcContext,
passwordValidators = {
"length": {
"ignore.empty.value": true,
"min": "4"
}
},
i18n
} = params;
const attributesWithPassword = useMemo(
() =>
!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<`\${${MessageKeyBase}}`>("${password}"),
"required": true,
"readOnly": false,
"validators": passwordValidators,
"annotations": {},
"groupAnnotations": {},
"autocomplete": "new-password"
}),
id<Attribute>({
"name": "password-confirm",
"displayName": id<`\${${MessageKeyBase}}`>("${passwordConfirm}"),
"required": true,
"readOnly": false,
"validators": {
"_compareToOther": {
"name": "password",
"ignore.empty.value": true,
"shouldBe": "equal",
"error-message": id<`\${${MessageKeyBase}}`>("${invalidPasswordConfirmMessage}")
}
},
"annotations": {},
"groupAnnotations": {},
"autocomplete": "new-password"
})
])
],
[]
);
})(),
[kcContext, passwordValidators]
);
const { getErrors } = useGetErrors({
"kcContext": {
"messagesPerField": kcContext.messagesPerField,
"profile": {
"attributes": attributesWithPassword
}
},
i18n
});
const initialInternalState = useMemo(
() =>
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
}
])
),
[attributesWithPassword]
);
type InternalState = typeof initialInternalState;
const [formValidationInternalState, formValidationDispatch] = 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, attributesWithPassword]
);
return {
formValidationState,
formValidationDispatch,
attributesWithPassword
};
}
/** Expect to be used in a component wrapped within a <I18nProvider> */
function useGetErrors(params: {
kcContext: {
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: { name: string; value?: string; validators: Validators }[];
};
};
i18n: I18nBase;
}) {
const { kcContext, i18n } = params;
const {
messagesPerField,
profile: { attributes }
} = kcContext;
const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n;
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)!;
block: {
if (defaultValue !== value) {
break block;
}
let doesErrorExist: boolean;
try {
doesErrorExist = messagesPerField.existsError(name);
} catch {
break block;
}
if (!doesErrorExist) {
break block;
}
const errorMessageStr = messagesPerField.get(name);
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<MessageKeyBase>(
(() => {
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<MessageKeyBase>("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 = [id<MessageKeyBase>("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;
}
}
scope: {
const validatorName = "options";
const validator = validators[validatorName];
if (validator === undefined) {
break scope;
}
if (value === "") {
break scope;
}
if (validator.options.indexOf(value) >= 0) {
break scope;
}
const msgArgs = [id<MessageKeyBase>("notAValidOption")] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArgs)
});
}
//TODO: Implement missing validators.
return errors;
});
return { getErrors };
}

View File

@ -1,4 +0,0 @@
/* eslint-disable @typescript-eslint/ban-types */
import type { FC, ComponentClass } from "react";
export type ReactComponent<Props extends Record<string, unknown> = {}> = ((props: Props) => ReturnType<FC>) | ComponentClass<Props>;

View File

@ -6,6 +6,180 @@ import { useConstCallback } from "./tools/useConstCallback";
import { id } from "tsafe/id";
import { emailRegexp } from "./tools/emailRegExp";
/** @deprecated: Will be removed in the next major. Use this instead:
* import { useFormValidation } from "keycloakify/lib/pages/shares/UserProfileCommons";
*
* The API is the same only the returned value formValidationReducer have been renamed formValidationDispatch
* (a it should have been named from the beginning 😬)
*/
export function useFormValidationSlice(params: {
kcContext: {
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: Attribute[];
};
passwordRequired?: boolean;
realm: { registrationEmailAsUsername: boolean };
};
/** NOTE: Try to avoid passing a new ref every render for better performances. */
passwordValidators?: Validators;
i18n: I18nBase;
}) {
const {
kcContext,
passwordValidators = {
"length": {
"ignore.empty.value": true,
"min": "4"
}
},
i18n
} = params;
const attributesWithPassword = useMemo(
() =>
!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<`\${${MessageKeyBase}}`>("${password}"),
"required": true,
"readOnly": false,
"validators": passwordValidators,
"annotations": {},
"groupAnnotations": {},
"autocomplete": "new-password"
}),
id<Attribute>({
"name": "password-confirm",
"displayName": id<`\${${MessageKeyBase}}`>("${passwordConfirm}"),
"required": true,
"readOnly": false,
"validators": {
"_compareToOther": {
"name": "password",
"ignore.empty.value": true,
"shouldBe": "equal",
"error-message": id<`\${${MessageKeyBase}}`>("${invalidPasswordConfirmMessage}")
}
},
"annotations": {},
"groupAnnotations": {},
"autocomplete": "new-password"
})
])
],
[]
);
})(),
[kcContext, passwordValidators]
);
const { getErrors } = useGetErrors({
"kcContext": {
"messagesPerField": kcContext.messagesPerField,
"profile": {
"attributes": attributesWithPassword
}
},
i18n
});
const initialInternalState = useMemo(
() =>
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
}
])
),
[attributesWithPassword]
);
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, attributesWithPassword]
);
return { formValidationState, formValidationReducer, attributesWithPassword };
}
/** Expect to be used in a component wrapped within a <I18nProvider> */
export function useGetErrors(params: {
kcContext: {
@ -303,175 +477,3 @@ export function useGetErrors(params: {
return { getErrors };
}
/**
* NOTE: The attributesWithPassword returned is actually augmented with
* artificial password related attributes only if kcContext.passwordRequired === true
*/
export function useFormValidationSlice(params: {
kcContext: {
messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: Attribute[];
};
passwordRequired?: boolean;
realm: { registrationEmailAsUsername: boolean };
};
/** NOTE: Try to avoid passing a new ref every render for better performances. */
passwordValidators?: Validators;
i18n: I18nBase;
}) {
const {
kcContext,
passwordValidators = {
"length": {
"ignore.empty.value": true,
"min": "4"
}
},
i18n
} = params;
const attributesWithPassword = useMemo(
() =>
!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<`\${${MessageKeyBase}}`>("${password}"),
"required": true,
"readOnly": false,
"validators": passwordValidators,
"annotations": {},
"groupAnnotations": {},
"autocomplete": "new-password"
}),
id<Attribute>({
"name": "password-confirm",
"displayName": id<`\${${MessageKeyBase}}`>("${passwordConfirm}"),
"required": true,
"readOnly": false,
"validators": {
"_compareToOther": {
"name": "password",
"ignore.empty.value": true,
"shouldBe": "equal",
"error-message": id<`\${${MessageKeyBase}}`>("${invalidPasswordConfirmMessage}")
}
},
"annotations": {},
"groupAnnotations": {},
"autocomplete": "new-password"
})
])
],
[]
);
})(),
[kcContext, passwordValidators]
);
const { getErrors } = useGetErrors({
"kcContext": {
"messagesPerField": kcContext.messagesPerField,
"profile": {
"attributes": attributesWithPassword
}
},
i18n
});
const initialInternalState = useMemo(
() =>
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
}
])
),
[attributesWithPassword]
);
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, attributesWithPassword]
);
return { formValidationState, formValidationReducer, attributesWithPassword };
}

View File

@ -839,14 +839,14 @@ escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
evt@^2.4.13:
version "2.4.13"
resolved "https://registry.yarnpkg.com/evt/-/evt-2.4.13.tgz#5ef873159ce62e099d58801a3e4b8e0f5b648017"
integrity sha512-haTVOsmjzk+28zpzvVwan9Zw2rLQF2izgi7BKjAPRzZAfcv+8scL0TpM8MzvGNKFYHiy+Bq3r6FYIIUPl9kt3A==
evt@^2.4.15:
version "2.4.15"
resolved "https://registry.yarnpkg.com/evt/-/evt-2.4.15.tgz#bf673dd2b409067fcdc182becd8e7dbed13692e1"
integrity sha512-jlDDDWdoDg2wIZa9m08HVelHzEAlZLpLbDcWMqlc6nqKHG1Z3ZqC7teDoNH3upClx3SKwJTC0H7RIMfxe7zX2g==
dependencies:
minimal-polyfills "^2.2.2"
run-exclusive "^2.2.18"
tsafe "^1.4.1"
tsafe "^1.4.3"
execa@^5.1.1:
version "5.1.1"
@ -1769,7 +1769,7 @@ ts-node@^10.9.1:
v8-compile-cache-lib "^3.0.1"
yn "3.1.1"
tsafe@^1.4.1, tsafe@^1.4.3:
tsafe@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.4.3.tgz#a98ce83616f0d9c01e3c6167a2ead45ba455b2ae"
integrity sha512-KjCdgjIqsbKW9oeJGSMVC23jhWm/VXJwkaZ7jffo/WaTioLGTHJqliHe9dECEVzIACNVNs/fwtKwU8wWK4jY4g==