Fully retrocompatible, factorized Register page 🚀

This commit is contained in:
Joseph Garrone 2024-05-06 21:27:36 +02:00
parent 96f0e6df2a
commit f0ffb3fc10
4 changed files with 257 additions and 66 deletions

View File

@ -1,8 +1,7 @@
import { useEffect, Fragment } from "react";
import type { ClassKey } from "keycloakify/login/TemplateProps";
import { clsx } from "keycloakify/tools/clsx";
import { useUserProfileForm, type KcContextLike, type FormAction, type FormFieldError } from "keycloakify/login/lib/useUserProfileForm";
import type { Attribute, LegacyAttribute } from "keycloakify/login/kcContext/KcContext";
import type { Attribute } from "keycloakify/login/kcContext/KcContext";
import { assert } from "tsafe/assert";
import type { I18n } from "./i18n";
@ -49,20 +48,9 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
return (
<>
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
const formGroupClassName = clsx(
getClassName("kcFormGroupClass"),
displayableErrors.length !== 0 && getClassName("kcFormGroupErrorClass")
);
return (
<Fragment key={attribute.name}>
<GroupLabel
attribute={attribute}
getClassName={getClassName}
i18n={i18n}
groupNameRef={groupNameRef}
formGroupClassName={formGroupClassName}
/>
<GroupLabel attribute={attribute} getClassName={getClassName} i18n={i18n} groupNameRef={groupNameRef} />
{BeforeField !== undefined && (
<BeforeField
attribute={attribute}
@ -73,7 +61,7 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps)
/>
)}
<div
className={formGroupClassName}
className={getClassName("kcFormGroupClass")}
style={{ "display": attribute.name === "password-confirm" && !doMakeUserConfirmPassword ? "none" : undefined }}
>
<div className={getClassName("kcLabelWrapperClass")}>
@ -142,41 +130,11 @@ function GroupLabel(props: {
groupNameRef: {
current: string;
};
formGroupClassName: string;
}) {
const { attribute, getClassName, i18n, groupNameRef, formGroupClassName } = props;
const { attribute, getClassName, i18n, groupNameRef } = props;
const { advancedMsg } = i18n;
keycloak_prior_to_24: {
if (attribute.html5DataAnnotations !== undefined) {
break keycloak_prior_to_24;
}
const { group = "", groupDisplayHeader = "", groupDisplayDescription = "" } = attribute as any as LegacyAttribute;
return (
<>
{group !== groupNameRef.current && (groupNameRef.current = group) !== "" && (
<div className={formGroupClassName}>
<div className={getClassName("kcContentWrapperClass")}>
<label id={`header-${group}`} className={getClassName("kcFormGroupHeader")}>
{advancedMsg(groupDisplayHeader) || groupNameRef.current}
</label>
</div>
{groupDisplayDescription !== "" && (
<div className={getClassName("kcLabelWrapperClass")}>
<label id={`description-${group}`} className={getClassName("kcLabelClass")}>
{advancedMsg(groupDisplayDescription)}
</label>
</div>
)}
</div>
)}
</>
);
}
if (attribute.group?.name !== groupNameRef.current) {
groupNameRef.current = attribute.group?.name ?? "";

View File

@ -193,7 +193,7 @@ export declare namespace KcContext {
profile: {
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
html5DataAnnotations: Record<string, string>;
html5DataAnnotations?: Record<string, string>;
};
url: {
registrationAction: string;
@ -450,8 +450,8 @@ export declare namespace KcContext {
export type UpdateUserProfile = Common & {
pageId: "update-user-profile.ftl";
profile: {
attributes: LegacyAttribute[];
attributesByName: Record<string, LegacyAttribute>;
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
};
@ -459,8 +459,8 @@ export declare namespace KcContext {
pageId: "idp-review-user-profile.ftl";
profile: {
context: "IDP_REVIEW";
attributes: LegacyAttribute[];
attributesByName: Record<string, LegacyAttribute>;
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
};
@ -517,7 +517,7 @@ export type Attribute = {
name: string;
displayDescription?: string;
};
html5DataAnnotations: {
html5DataAnnotations?: {
kcNumberFormat?: string;
kcNumberUnFormat?: string;
};
@ -599,13 +599,6 @@ export type Attribute = {
| "photo";
};
export type LegacyAttribute = Omit<Attribute, "group" | "html5DataAnnotations"> & {
group: string;
groupDisplayHeader?: string;
groupDisplayDescription?: string;
groupAnnotations: Record<string, string>;
};
export type Validators = Partial<{
length: Validators.DoIgnoreEmpty & Validators.Range;
integer: Validators.DoIgnoreEmpty & Validators.Range;

View File

@ -0,0 +1,182 @@
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function Register(props: PageProps<Extract<KcContext, { pageId: "register.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { getClassName } = useGetClassName({
doUseDefaultCss,
classes
});
const { url, messagesPerField, register, realm, passwordRequired, recaptchaRequired, recaptchaSiteKey } = kcContext;
const { msg, msgStr } = i18n;
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("registerTitle")}>
<form id="kc-register-form" className={getClassName("kcFormClass")} action={url.registrationAction} method="post">
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("firstName", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="firstName" className={getClassName("kcLabelClass")}>
{msg("firstName")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="firstName"
className={getClassName("kcInputClass")}
name="firstName"
defaultValue={register.formData.firstName ?? ""}
/>
</div>
</div>
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("lastName", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="lastName" className={getClassName("kcLabelClass")}>
{msg("lastName")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="lastName"
className={getClassName("kcInputClass")}
name="lastName"
defaultValue={register.formData.lastName ?? ""}
/>
</div>
</div>
<div
className={clsx(getClassName("kcFormGroupClass"), messagesPerField.printIfExists("email", getClassName("kcFormGroupErrorClass")))}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="email" className={getClassName("kcLabelClass")}>
{msg("email")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="email"
className={getClassName("kcInputClass")}
name="email"
defaultValue={register.formData.email ?? ""}
autoComplete="email"
/>
</div>
</div>
{!realm.registrationEmailAsUsername && (
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("username", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="username" className={getClassName("kcLabelClass")}>
{msg("username")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="text"
id="username"
className={getClassName("kcInputClass")}
name="username"
defaultValue={register.formData.username ?? ""}
autoComplete="username"
/>
</div>
</div>
)}
{passwordRequired && (
<>
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("password", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="password" className={getClassName("kcLabelClass")}>
{msg("password")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input
type="password"
id="password"
className={getClassName("kcInputClass")}
name="password"
autoComplete="new-password"
/>
</div>
</div>
<div
className={clsx(
getClassName("kcFormGroupClass"),
messagesPerField.printIfExists("password-confirm", getClassName("kcFormGroupErrorClass"))
)}
>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="password-confirm" className={getClassName("kcLabelClass")}>
{msg("passwordConfirm")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input type="password" id="password-confirm" className={getClassName("kcInputClass")} name="password-confirm" />
</div>
</div>
</>
)}
{recaptchaRequired && (
<div className="form-group">
<div className={getClassName("kcInputWrapperClass")}>
<div className="g-recaptcha" data-size="compact" data-sitekey={recaptchaSiteKey}></div>
</div>
</div>
)}
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")}>
<span>
<a href={url.loginUrl}>{msg("backToLogin")}</a>
</span>
</div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
type="submit"
value={msgStr("doRegister")}
/>
</div>
</div>
</form>
</Template>
);
}

View File

@ -68,7 +68,7 @@ export type KcContextLike = {
messagesPerField: Pick<KcContext.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: Attribute[];
html5DataAnnotations: Record<string, string>;
html5DataAnnotations?: Record<string, string>;
};
passwordRequired: boolean;
realm: { registrationEmailAsUsername: boolean };
@ -107,8 +107,7 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
usePrepareTemplate({
"styles": [],
// NOTE: The ?? {} is for compat with Keycloak version prior to 24
"scripts": Object.keys(kcContext.profile.html5DataAnnotations ?? {})
"scripts": Object.keys(kcContext.profile?.html5DataAnnotations ?? {})
.filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it.
.map(key => ({
"isModule": true,
@ -126,7 +125,69 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
const attributesWithPassword = useMemo(() => {
const attributesWithPassword: Attribute[] = [];
for (const attribute of kcContext.profile.attributes) {
const attributes = (() => {
retrocompat_patch: {
if ("profile" in kcContext && "attributes" in kcContext.profile && kcContext.profile.attributes.length !== 0) {
break retrocompat_patch;
}
kcContext.profile = {
"attributes": (["firstName", "lastName", "email", "username"] as const)
.filter(name => (name !== "username" ? true : !kcContext.realm.registrationEmailAsUsername))
.map(name =>
id<Attribute>({
"name": name,
"displayName": id<`\${${MessageKey}}`>(`\${${name}}`),
"required": true,
"value": (kcContext as any).register.formData[name] ?? "",
"html5DataAnnotations": {},
"readOnly": false,
"validators": {},
"annotations": {},
"autocomplete": (() => {
switch (name) {
case "email":
return "email";
case "username":
return "username";
default:
return undefined;
}
})()
})
),
"html5DataAnnotations": {}
};
}
return kcContext.profile.attributes;
})();
for (const attribute_pre_group_patch of attributes) {
const attribute = (() => {
if (typeof attribute_pre_group_patch.group === "string" && attribute_pre_group_patch.group !== "") {
const { group, groupDisplayHeader, groupDisplayDescription, groupAnnotations, ...rest } =
attribute_pre_group_patch as Attribute & {
group: string;
groupDisplayHeader?: string;
groupDisplayDescription?: string;
groupAnnotations: Record<string, string>;
};
return id<Attribute>({
...rest,
"group": {
"name": group,
"displayHeader": groupDisplayHeader,
"displayDescription": groupDisplayDescription,
"html5DataAnnotations": {}
}
});
}
return attribute_pre_group_patch;
})();
attributesWithPassword.push(attribute);
add_password_and_password_confirm: {
@ -191,7 +252,6 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
apply_formatters: {
const { attribute } = formFieldState;
// NOTE: The `?? {}` is for compat with Keycloak version prior to 24
const { kcNumberFormat } = attribute.html5DataAnnotations ?? {};
if (kcNumberFormat === undefined) {
@ -407,7 +467,6 @@ function useGetErrors(params: { kcContext: Pick<KcContextLike, "messagesPerField
let { valueOrValues } = formFieldState;
unFormat_number: {
// NOTE: The `?? {}` is for compat with Keycloak version prior to 24
const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {};
if (kcNumberUnFormat === undefined) {
@ -791,7 +850,6 @@ function useGetErrors(params: { kcContext: Pick<KcContextLike, "messagesPerField
assert(typeof valueOrValues === "string");
unFormat_number: {
// NOTE: The `?? {}` is for compat with Keycloak version prior to 24
const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {};
if (kcNumberUnFormat === undefined) {