Support register-user-profile.ftl

This commit is contained in:
garronej 2021-10-11 03:25:02 +02:00
parent c388c77f4a
commit 4ca2bc59b6
8 changed files with 346 additions and 128 deletions

View File

@ -134,97 +134,67 @@
obj,
{
"messagesPerField": {
"printIfExists": function (key, x) {
switch(key){
case "userLabel": return (function (){
<#attempt>
return "${messagesPerField.printIfExists('userLabel','1')}" ? x : undefined;
<#recover>
</#attempt>
})();
case "username": return (function (){
<#attempt>
return "${messagesPerField.printIfExists('username','1')}" ? x : undefined;
<#recover>
</#attempt>
})();
case "email": return (function (){
<#attempt>
return "${messagesPerField.printIfExists('email','1')}" ? x : undefined;
<#recover>
</#attempt>
})();
case "firstName": return (function (){
<#attempt>
return "${messagesPerField.printIfExists('firstName','1')}" ? x : undefined;
<#recover>
</#attempt>
})();
case "lastName": return (function (){
<#attempt>
return "${messagesPerField.printIfExists('lastName','1')}" ? x : undefined;
<#recover>
</#attempt>
})();
case "password": return (function (){
<#attempt>
return "${messagesPerField.printIfExists('password','1')}" ? x : undefined;
<#recover>
</#attempt>
})();
case "password-confirm": return (function (){
<#attempt>
return "${messagesPerField.printIfExists('password-confirm','1')}" ? x : undefined;
<#recover>
</#attempt>
})();
}
},
"existsError": function (key) {
<#assign fieldNames = ["global", "userLabel", "username", "email", "firstName", "lastName", "password", "password-confirm"]>
<#attempt>
<#list profile.attributes as attribute>
if(key === "${attribute.name}" ){
return <#if messagesPerField.existsError('${attribute.name}')>true<#else>false</#if>;
}
<#assign fieldNames += [attribute.name]>
</#list>
<#recover>
</#attempt>
throw new Error(key + " is not an exsisting profile attribute name");
},
"get": function (key) {
"printIfExists": function (fieldName, x) {
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
<#list profile.attributes as attribute>
<#if messagesPerField.existsError('${attribute.name}')>
if(key === "${attribute.name}" ){
return "${messagesPerField.get('${attribute.name}')?no_esc}"
return "${messagesPerField.printIfExists(fieldName,'1')}" ? x : undefined;
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName " field");
},
"existsError": function (fieldName) {
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
return <#if messagesPerField.existsError('${fieldName}')>true<#else>false</#if>;
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName " field");
},
"get": function (fieldName) {
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
<#if messagesPerField.existsError('${fieldName}')>
if(fieldName === "${fieldName}" ){
return "${messagesPerField.get('${fieldName}')?no_esc}";
}
</#if>
</#list>
<#recover>
</#attempt>
throw new Error(" there is no message for " + key);
}
</#list>
throw new Error("There is no " + fieldName " field");
},
"exists": function (fieldName) {
<#list fieldNames as fieldName>
if(fieldName === "${fieldName}" ){
<#attempt>
return <#if messagesPerField.exists('${fieldName}')>true<#else>false</#if>;
<#recover>
</#attempt>
}
</#list>
throw new Error("There is no " + fieldName " field");
}
},
"msg": function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); },
"advancedMsg": function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); },
"advancedMsg": function(){ throw new Error("use import { useKcMessage } from 'keycloakify'"); }
}
);

View File

@ -1,5 +1,3 @@
import cheerio from "cheerio";
import {
replaceImportsFromStaticInJsCode,
@ -12,11 +10,10 @@ import { objectKeys } from "tsafe/objectKeys";
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
export const pageIds = [
"login.ftl", "register.ftl", "info.ftl",
"error.ftl", "login-reset-password.ftl",
"login-verify-email.ftl", "terms.ftl",
"login-otp.ftl", "login-update-profile.ftl",
"login-idp-link-confirm.ftl"
"login.ftl", "register.ftl", "register-user-profile.ftl",
"info.ftl", "error.ftl", "login-reset-password.ftl",
"login-verify-email.ftl", "terms.ftl", "login-otp.ftl",
"login-update-profile.ftl", "login-idp-link-confirm.ftl"
] as const;
export type PageId = typeof pageIds[number];

View File

@ -4,6 +4,7 @@ import type { KcContextBase } from "../getKcContext/KcContextBase";
import type { KcProps } from "./KcProps";
import { Login } from "./Login";
import { Register } from "./Register";
import { RegisterUserProfile } from "./RegisterUserProfile";
import { Info } from "./Info";
import { Error } from "./Error";
import { LoginResetPassword } from "./LoginResetPassword";
@ -17,6 +18,7 @@ export const KcApp = memo(({ kcContext, ...props }: { kcContext: KcContextBase;
switch (kcContext.pageId) {
case "login.ftl": return <Login {...{ kcContext, ...props }} />;
case "register.ftl": return <Register {...{ kcContext, ...props }} />;
case "register-user-profile.ftl": return <RegisterUserProfile {...{ kcContext, ...props }} />;
case "info.ftl": return <Info {...{ kcContext, ...props }} />;
case "error.ftl": return <Error {...{ kcContext, ...props }} />;
case "login-reset-password.ftl": return <LoginResetPassword {...{ kcContext, ...props }} />;

View File

@ -94,12 +94,14 @@ export type KcProps = KcPropsGeneric<
"kcFormGroupErrorClass" |
"kcLabelClass" |
"kcInputClass" |
"kcInputErrorMessageClass" |
"kcInputWrapperClass" |
"kcFormOptionsClass" |
"kcFormButtonsClass" |
"kcFormSettingClass" |
"kcTextareaClass" |
"kcInfoAreaClass" |
"kcFormGroupHeader" |
"kcButtonClass" |
"kcButtonPrimaryClass" |
"kcButtonDefaultClass" |
@ -147,6 +149,7 @@ export const defaultKcProps = {
"kcFormGroupErrorClass": ["has-error"],
"kcLabelClass": ["control-label"],
"kcInputClass": ["form-control"],
"kcInputErrorMessageClass": ["pf-c-form__helper-text", "pf-m-error", "required", "kc-feedback-text"],
"kcInputWrapperClass": ["col-xs-12", "col-sm-12", "col-md-12", "col-lg-12"],
"kcFormOptionsClass": ["col-xs-12", "col-sm-12", "col-md-12", "col-lg-12"],
"kcFormButtonsClass": ["col-xs-12", "col-sm-12", "col-md-12", "col-lg-12"],
@ -155,6 +158,9 @@ export const defaultKcProps = {
"kcInfoAreaClass": ["col-xs-12", "col-sm-4", "col-md-4", "col-lg-5", "details"],
// user-profile grouping
"kcFormGroupHeader": ["pf-c-form__group"],
// css classes for form buttons main class used for all buttons
"kcButtonClass": ["btn"],
// classes defining priority of the button - primary or default (there is typically only one priority button for the form)

View File

@ -0,0 +1,226 @@
import { memo, Fragment } from "react";
import { Template } from "./Template";
import type { KcProps } from "./KcProps";
import type { KcContextBase } from "../getKcContext/KcContextBase";
import { useKcMessage } from "../i18n/useKcMessage";
import { useCssAndCx } from "tss-react";
import type { ReactComponent } from "../tools/ReactComponent";
export const RegisterUserProfile = memo(({ kcContext, ...props }: { kcContext: KcContextBase.RegisterUserProfile; } & KcProps) => {
const {
url,
messagesPerField,
realm,
passwordRequired,
recaptchaRequired,
recaptchaSiteKey
} = kcContext;
const { msg, msgStr } = useKcMessage();
const { cx } = useCssAndCx();
return (
<Template
{...{ kcContext, ...props }}
displayMessage={messagesPerField.exists("global")}
displayRequiredFields={true}
doFetchDefaultThemeResources={true}
headerNode={msg("registerTitle")}
formNode={
<form
id="kc-register-form"
className={cx(props.kcFormClass)}
action={url.registrationAction}
method="post"
>
<UserProfileFormFields
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"
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 &&
<div className="form-group">
<div className={cx(props.kcInputWrapperClass)}>
<div className="g-recaptcha" data-size="compact" data-sitekey={recaptchaSiteKey} />
</div>
</div>
}
<div className={cx(props.kcFormGroupClass)}>
<div id="kc-form-options" className={cx(props.kcFormOptionsClass)}>
<div className={cx(props.kcFormOptionsWrapperClass)}>
<span><a href={url.loginUrl}>{msg("backToLogin")}</a></span>
</div>
</div>
<div id="kc-form-buttons" className={cx(props.kcFormButtonsClass)}>
<input
className={cx(props.kcButtonClass, props.kcButtonPrimaryClass, props.kcButtonBlockClass, props.kcButtonLargeClass)}
type="submit" value={msgStr("doRegister")}
/>
</div>
</div>
</form >
}
/>
);
});
const UserProfileFormFields = memo(
(
{
kcContext,
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 { advancedMsg } = useKcMessage();
let currentGroup = "";
return (
<>
{kcContext.profile.attributes.map((attribute, i) => {
const {
group = "",
groupDisplayHeader = "",
groupDisplayDescription = ""
} = attribute;
if (group === currentGroup) return null;
currentGroup = group;
return (
<Fragment key={i}>
{group !== "" &&
<div className={cx(props.kcFormGroupClass)}>
<div className={cx(props.kcContentWrapperClass)}>
<label
id={`header-${group}`}
className={cx(props.kcFormGroupHeader)}
>
{groupDisplayHeader !== "" && advancedMsg(groupDisplayHeader) || currentGroup}
</label>
</div>
{groupDisplayDescription !== "" &&
<div className={cx(props.kcLabelWrapperClass)}>
<label
id={`description-${group}`}
className={`${cx(props.kcLabelClass)}`}
>
{advancedMsg(groupDisplayDescription) ?? ""}
</label>
</div>
}
</div>}
<BeforeField attribute={attribute} />
<div className={cx(props.kcFormGroupClass)}>
<div className={cx(props.kcLabelWrapperClass)}>
<label
htmlFor={attribute.name}
className={cx(props.kcLabelClass)}
>
{advancedMsg(attribute.displayName ?? "")}
</label>
{attribute.required && <>*</>}
</div>
<div className={cx(props.kcInputWrapperClass)}>
<input
type="text"
id={attribute.name}
name={attribute.name}
value={attribute.value ?? ""}
className={cx(props.kcInputClass)}
aria-invalid={messagesPerField.existsError(attribute.name)}
disabled={attribute.readOnly}
{...(attribute.autocomplete === undefined ? {} : {
"autoComplete": attribute.autocomplete
})}
/>
{
kcContext.messagesPerField.existsError(attribute.name) &&
<span
id={`input-error-${attribute.name}`}
className={cx(props.kcInputErrorMessageClass)}
aria-live="polite"
>
{messagesPerField.get(attribute.name)}
</span>
}
</div >
</div >
<AfterField attribute={attribute} />
</Fragment>
);
})}
</>
);
}
);

View File

@ -13,7 +13,7 @@ type ExtractAfterStartingWith<Prefix extends string, StrEnum> =
* (ex: url.loginAction is undefined on error.ftl)
*/
export type KcContextBase =
KcContextBase.Login | KcContextBase.Register | KcContextBase.Info |
KcContextBase.Login | KcContextBase.Register | KcContextBase.RegisterUserProfile | KcContextBase.Info |
KcContextBase.Error | KcContextBase.LoginResetPassword | KcContextBase.LoginVerifyEmail |
KcContextBase.Terms | KcContextBase.LoginOtp | KcContextBase.LoginUpdateProfile |
KcContextBase.LoginIdpLinkConfirm;
@ -62,6 +62,12 @@ export declare namespace KcContextBase {
name?: string;
}
isAppInitiatedAction: boolean;
messagesPerField: {
printIfExists: <T>(fieldName: string, x: T) => T | undefined;
existsError: (fieldName: string) => boolean;
get: (fieldName: string) => string;
exists: (fieldName: string) => boolean;
};
};
export type Login = Common & {
@ -97,35 +103,10 @@ export declare namespace KcContextBase {
};
};
export type Register = Common & {
pageId: "register.ftl";
export type RegisterCommon = Common & {
url: {
registrationAction: string;
};
messagesPerField: {
printIfExists: <T>(
key:
"userLabel" |
"username" |
"email" |
"firstName" |
"lastName" |
"password" |
"password-confirm",
x: T
)=> T | undefined;
existsError: (key: string)=> boolean;
get: (key: string) => string;
};
register: {
formData: {
firstName?: string;
displayName?: string;
lastName?: string;
email?: string;
username?: string;
}
};
passwordRequired: boolean;
recaptchaRequired: boolean;
recaptchaSiteKey?: string;
@ -140,6 +121,37 @@ export declare namespace KcContextBase {
};
};
export type Register = RegisterCommon & {
pageId: "register.ftl";
register: {
formData: {
firstName?: string;
displayName?: string;
lastName?: string;
email?: string;
username?: string;
}
};
};
export type RegisterUserProfile = RegisterCommon & {
pageId: "register-user-profile.ftl";
profile: {
attributes: {
name: string;
displayName?: string;
required: boolean;
value?: string;
group?: string;
groupDisplayHeader?: string;
groupDisplayDescription?: string;
readOnly: boolean;
autocomplete?: string;
}[];
}
};
export type Info = Common & {
pageId: "info.ftl";
messageHeader?: string;
@ -191,13 +203,6 @@ export declare namespace KcContextBase {
firstName?: string;
lastName?: string;
};
messagesPerField: {
printIfExists<T>(
key: "username" | "email" | "firstName" | "lastName",
x: T
): T | undefined;
};
};
export type LoginIdpLinkConfirm = Common & {

View File

@ -5,6 +5,9 @@ import { kcMessages, evtTermsUpdated } from "./kcMessages/login";
import { useEvt } from "evt/hooks";
//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35
import ReactMarkdown from "react-markdown";
import { id } from "tsafe/id";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
export type MessageKey = keyof typeof kcMessages["en"];
@ -53,7 +56,7 @@ export function useKcMessage() {
);
const advancedMsg = useCallback(
(key: string): string => {
(key: string): string | undefined => {
const match = key.match(/^\$\{([^{]+)\}$/);
@ -61,7 +64,10 @@ export function useKcMessage() {
return key;
}
return msgStr(match[1] as MessageKey);
return (
id<Record<string, string | undefined>>(kcMessages[kcLanguageTag])[key] ??
id<Record<string, string | undefined>>(kcMessages["en"])[key]
);
},
[msgStr]

View File

@ -0,0 +1,6 @@
import type { FC, ComponentClass } from "react";
export type ReactComponent<Props extends Record<string, unknown> = {}> =
| ((props: Props) => ReturnType<FC>)
| ComponentClass<Props>;