From 5892cf2ba74448abd6adbc0fcfe9753024bd8e55 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 00:19:37 +0200 Subject: [PATCH 01/82] Decouple user profile form logic so it can be consumed in angular --- src/login/lib/getUserProfileApi.ts | 133 ++ src/login/lib/useUserProfileForm copy.tsx | 1403 +++++++++++++++++++++ src/login/lib/useUserProfileForm.tsx | 1401 ++------------------ 3 files changed, 1616 insertions(+), 1321 deletions(-) create mode 100644 src/login/lib/getUserProfileApi.ts create mode 100644 src/login/lib/useUserProfileForm copy.tsx diff --git a/src/login/lib/getUserProfileApi.ts b/src/login/lib/getUserProfileApi.ts new file mode 100644 index 00000000..5cc88463 --- /dev/null +++ b/src/login/lib/getUserProfileApi.ts @@ -0,0 +1,133 @@ +import "keycloakify/tools/Array.prototype.every"; +import { assert } from "tsafe/assert"; +import type { + PasswordPolicies, + Attribute, + Validators +} from "keycloakify/login/KcContext"; +import type { KcContext } from "../KcContext"; +import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n"; + +export type FormFieldError = { + advancedMsgArgs: [string, ...string[]]; + source: FormFieldError.Source; + fieldIndex: number | undefined; +}; + +export namespace FormFieldError { + export type Source = + | Source.Validator + | Source.PasswordPolicy + | Source.Server + | Source.Other; + + export namespace Source { + export type Validator = { + type: "validator"; + name: keyof Validators; + }; + export type PasswordPolicy = { + type: "passwordPolicy"; + name: keyof PasswordPolicies; + }; + export type Server = { + type: "server"; + }; + + export type Other = { + type: "other"; + rule: "passwordConfirmMatchesPassword" | "requiredField"; + }; + } +} + +export type FormFieldState = { + attribute: Attribute; + displayableErrors: FormFieldError[]; + valueOrValues: string | string[]; +}; + +export type FormState = { + isFormSubmittable: boolean; + formFieldStates: FormFieldState[]; +}; + +export type FormAction = + | { + action: "update"; + name: string; + valueOrValues: string | string[]; + /** Default false */ + displayErrorsImmediately?: boolean; + } + | { + action: "focus lost"; + name: string; + fieldIndex: number | undefined; + }; + +export type KcContextLike = KcContextLike_i18n & + KcContextLike_useGetErrors & { + profile: { + attributesByName: Record; + html5DataAnnotations?: Record; + }; + passwordRequired?: boolean; + realm: { registrationEmailAsUsername: boolean }; + url: { + resourcesPath: string; + }; + }; + +type KcContextLike_useGetErrors = KcContextLike_i18n & { + messagesPerField: Pick; + passwordPolicies?: PasswordPolicies; +}; + +assert< + Extract< + Extract, + { pageId: "register.ftl" } + > extends KcContextLike + ? true + : false +>(); + +export type UserProfileApi = { + getFormState: () => FormState; + subscribeToFormState: (callback: () => void) => { unsubscribe: () => void }; + dispatchFormAction: (action: FormAction) => void; +}; + +const cachedUserProfileApiByKcContext = new WeakMap(); + +export type ParamsOfGetUserProfileApi = { + kcContext: KcContextLike; + doMakeUserConfirmPassword: boolean; +}; + +export function getUserProfileApi(params: ParamsOfGetUserProfileApi): UserProfileApi { + const { kcContext } = params; + + use_cache: { + const userProfileApi_cache = cachedUserProfileApiByKcContext.get(kcContext); + + if (userProfileApi_cache === undefined) { + break use_cache; + } + + return userProfileApi_cache; + } + + const userProfileApi = getUserProfileApi_noCache(params); + + cachedUserProfileApiByKcContext.set(kcContext, userProfileApi); + + return userProfileApi; +} + +export function getUserProfileApi_noCache( + params: ParamsOfGetUserProfileApi +): UserProfileApi { + return null as any; +} diff --git a/src/login/lib/useUserProfileForm copy.tsx b/src/login/lib/useUserProfileForm copy.tsx new file mode 100644 index 00000000..31ad939e --- /dev/null +++ b/src/login/lib/useUserProfileForm copy.tsx @@ -0,0 +1,1403 @@ +import "keycloakify/tools/Array.prototype.every"; +import { useMemo, useReducer, useEffect, Fragment, type Dispatch } from "react"; +import { assert, type Equals } from "tsafe/assert"; +import { id } from "tsafe/id"; +import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions"; +import { kcSanitize } from "keycloakify/lib/kcSanitize"; +import { useConstCallback } from "keycloakify/tools/useConstCallback"; +import { emailRegexp } from "keycloakify/tools/emailRegExp"; +import { formatNumber } from "keycloakify/tools/formatNumber"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext"; +import type { KcContext } from "../KcContext"; +import type { MessageKey_defaultSet } from "keycloakify/login/i18n"; +import type { I18n } from "../i18n"; + +export type FormFieldError = { + errorMessage: JSX.Element; + errorMessageStr: string; + source: FormFieldError.Source; + fieldIndex: number | undefined; +}; + +export namespace FormFieldError { + export type Source = Source.Validator | Source.PasswordPolicy | Source.Server | Source.Other; + + export namespace Source { + export type Validator = { + type: "validator"; + name: keyof Validators; + }; + export type PasswordPolicy = { + type: "passwordPolicy"; + name: keyof PasswordPolicies; + }; + export type Server = { + type: "server"; + }; + + export type Other = { + type: "other"; + rule: "passwordConfirmMatchesPassword" | "requiredField"; + }; + } +} + +export type FormFieldState = { + attribute: Attribute; + displayableErrors: FormFieldError[]; + valueOrValues: string | string[]; +}; + +export type FormState = { + isFormSubmittable: boolean; + formFieldStates: FormFieldState[]; +}; + +export type FormAction = + | { + action: "update"; + name: string; + valueOrValues: string | string[]; + /** Default false */ + displayErrorsImmediately?: boolean; + } + | { + action: "focus lost"; + name: string; + fieldIndex: number | undefined; + }; + +export type KcContextLike = KcContextLike_useGetErrors & { + profile: { + attributesByName: Record; + html5DataAnnotations?: Record; + }; + passwordRequired?: boolean; + realm: { registrationEmailAsUsername: boolean }; + url: { + resourcesPath: string; + }; +}; + +assert, { pageId: "register.ftl" }> extends KcContextLike ? true : false>(); + +export type UseUserProfileFormParams = { + kcContext: KcContextLike; + i18n: I18n; + doMakeUserConfirmPassword: boolean; +}; + +export type ReturnTypeOfUseUserProfileForm = { + formState: FormState; + dispatchFormAction: Dispatch; +}; + +namespace internal { + export type FormFieldState = { + attribute: Attribute; + errors: FormFieldError[]; + hasLostFocusAtLeastOnce: boolean | boolean[]; + valueOrValues: string | string[]; + }; + + export type State = { + formFieldStates: FormFieldState[]; + }; +} + +export function useUserProfileForm(params: UseUserProfileFormParams): ReturnTypeOfUseUserProfileForm { + const { kcContext, i18n, doMakeUserConfirmPassword } = params; + + const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "useUserProfileForm", + scriptTags: Object.keys(kcContext.profile?.html5DataAnnotations ?? {}) + .filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it. + .map(key => ({ + type: "module", + src: `${kcContext.url.resourcesPath}/js/${key}.js` + })) + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + const { getErrors } = useGetErrors({ + kcContext, + i18n + }); + + const initialState = useMemo((): internal.State => { + // NOTE: We don't use te kcContext.profile.attributes directly because + // they don't includes the password and password confirm fields and we want to add them. + // We also want to apply some retro-compatibility and consistency patches. + const attributes: Attribute[] = (() => { + mock_user_profile_attributes_for_older_keycloak_versions: { + if ( + "profile" in kcContext && + "attributesByName" in kcContext.profile && + Object.keys(kcContext.profile.attributesByName).length !== 0 + ) { + break mock_user_profile_attributes_for_older_keycloak_versions; + } + + if ("register" in kcContext && kcContext.register instanceof Object && "formData" in kcContext.register) { + //NOTE: Handle legacy register.ftl page + return (["firstName", "lastName", "email", "username"] as const) + .filter(name => (name !== "username" ? true : !kcContext.realm.registrationEmailAsUsername)) + .map(name => + id({ + name: name, + displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), + required: true, + value: (kcContext.register as any).formData[name] ?? "", + html5DataAnnotations: {}, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: (() => { + switch (name) { + case "email": + return "email"; + case "username": + return "username"; + default: + return undefined; + } + })() + }) + ); + } + + if ("user" in kcContext && kcContext.user instanceof Object) { + //NOTE: Handle legacy login-update-profile.ftl + return (["username", "email", "firstName", "lastName"] as const) + .filter(name => (name !== "username" ? true : (kcContext.user as any).editUsernameAllowed)) + .map(name => + id({ + name: name, + displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), + required: true, + value: (kcContext as any).user[name] ?? "", + html5DataAnnotations: {}, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: (() => { + switch (name) { + case "email": + return "email"; + case "username": + return "username"; + default: + return undefined; + } + })() + }) + ); + } + + if ("email" in kcContext && kcContext.email instanceof Object) { + //NOTE: Handle legacy update-email.ftl + return [ + id({ + name: "email", + displayName: id<`\${${MessageKey_defaultSet}}`>(`\${email}`), + required: true, + value: (kcContext.email as any).value ?? "", + html5DataAnnotations: {}, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: "email" + }) + ]; + } + + assert(false, "Unable to mock user profile from the current kcContext"); + } + + return Object.values(kcContext.profile.attributesByName).map(structuredCloneButFunctions); + })(); + + // Retro-compatibility and consistency patches + attributes.forEach(attribute => { + patch_legacy_group: { + if (typeof attribute.group !== "string") { + break patch_legacy_group; + } + + const { group, groupDisplayHeader, groupDisplayDescription, groupAnnotations } = attribute as Attribute & { + group: string; + groupDisplayHeader?: string; + groupDisplayDescription?: string; + groupAnnotations: Record; + }; + + delete attribute.group; + // @ts-expect-error + delete attribute.groupDisplayHeader; + // @ts-expect-error + delete attribute.groupDisplayDescription; + // @ts-expect-error + delete attribute.groupAnnotations; + + if (group === "") { + break patch_legacy_group; + } + + attribute.group = { + name: group, + displayHeader: groupDisplayHeader, + displayDescription: groupDisplayDescription, + annotations: groupAnnotations, + html5DataAnnotations: {} + }; + } + + // Attributes with options rendered by default as select inputs + if (attribute.validators.options !== undefined && attribute.annotations.inputType === undefined) { + attribute.annotations.inputType = "select"; + } + + // Consistency patch on values/value property + { + if (getIsMultivaluedSingleField({ attribute })) { + attribute.multivalued = true; + } + + if (attribute.multivalued) { + attribute.values ??= attribute.value !== undefined ? [attribute.value] : []; + delete attribute.value; + } else { + attribute.value ??= attribute.values?.[0]; + delete attribute.values; + } + } + }); + + add_password_and_password_confirm: { + if (!kcContext.passwordRequired) { + break add_password_and_password_confirm; + } + + attributes.forEach((attribute, i) => { + if (attribute.name !== (kcContext.realm.registrationEmailAsUsername ? "email" : "username")) { + // NOTE: We want to add password and password-confirm after the field that identifies the user. + // It's either email or username. + return; + } + + attributes.splice( + i + 1, + 0, + { + name: "password", + displayName: id<`\${${MessageKey_defaultSet}}`>("${password}"), + required: true, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: "new-password", + html5DataAnnotations: {} + }, + { + name: "password-confirm", + displayName: id<`\${${MessageKey_defaultSet}}`>("${passwordConfirm}"), + required: true, + readOnly: false, + validators: {}, + annotations: {}, + html5DataAnnotations: {}, + autocomplete: "new-password" + } + ); + }); + } + + const initialFormFieldState: { + attribute: Attribute; + valueOrValues: string | string[]; + }[] = []; + + for (const attribute of attributes) { + handle_multi_valued_attribute: { + if (!attribute.multivalued) { + break handle_multi_valued_attribute; + } + + const values = attribute.values?.length ? attribute.values : [""]; + + apply_validator_min_range: { + if (getIsMultivaluedSingleField({ attribute })) { + break apply_validator_min_range; + } + + const validator = attribute.validators.multivalued; + + if (validator === undefined) { + break apply_validator_min_range; + } + + const { min: minStr } = validator; + + if (!minStr) { + break apply_validator_min_range; + } + + const min = parseInt(`${minStr}`); + + for (let index = values.length; index < min; index++) { + values.push(""); + } + } + + initialFormFieldState.push({ + attribute, + valueOrValues: values + }); + + continue; + } + + initialFormFieldState.push({ + attribute, + valueOrValues: attribute.value ?? "" + }); + } + + const initialState: internal.State = { + formFieldStates: initialFormFieldState.map(({ attribute, valueOrValues }) => ({ + attribute, + errors: getErrors({ + attributeName: attribute.name, + formFieldStates: initialFormFieldState + }), + hasLostFocusAtLeastOnce: + valueOrValues instanceof Array && !getIsMultivaluedSingleField({ attribute }) ? valueOrValues.map(() => false) : false, + valueOrValues: valueOrValues + })) + }; + + return initialState; + }, []); + + const [state, dispatchFormAction] = useReducer(function reducer(state: internal.State, formAction: FormAction): internal.State { + const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === formAction.name); + + assert(formFieldState !== undefined); + + (() => { + switch (formAction.action) { + case "update": + formFieldState.valueOrValues = formAction.valueOrValues; + + apply_formatters: { + const { attribute } = formFieldState; + + const { kcNumberFormat } = attribute.html5DataAnnotations ?? {}; + + if (!kcNumberFormat) { + break apply_formatters; + } + + if (formFieldState.valueOrValues instanceof Array) { + formFieldState.valueOrValues = formFieldState.valueOrValues.map(value => formatNumber(value, kcNumberFormat)); + } else { + formFieldState.valueOrValues = formatNumber(formFieldState.valueOrValues, kcNumberFormat); + } + } + + formFieldState.errors = getErrors({ + attributeName: formAction.name, + formFieldStates: state.formFieldStates + }); + + simulate_focus_lost: { + const { displayErrorsImmediately = false } = formAction; + + if (!displayErrorsImmediately) { + break simulate_focus_lost; + } + + for (const fieldIndex of formAction.valueOrValues instanceof Array + ? formAction.valueOrValues.map((...[, index]) => index) + : [undefined]) { + state = reducer(state, { + action: "focus lost", + name: formAction.name, + fieldIndex + }); + } + } + + update_password_confirm: { + if (doMakeUserConfirmPassword) { + break update_password_confirm; + } + + if (formAction.name !== "password") { + break update_password_confirm; + } + + state = reducer(state, { + action: "update", + name: "password-confirm", + valueOrValues: formAction.valueOrValues, + displayErrorsImmediately: formAction.displayErrorsImmediately + }); + } + + trigger_password_confirm_validation_on_password_change: { + if (!doMakeUserConfirmPassword) { + break trigger_password_confirm_validation_on_password_change; + } + + if (formAction.name !== "password") { + break trigger_password_confirm_validation_on_password_change; + } + + state = reducer(state, { + action: "update", + name: "password-confirm", + valueOrValues: (() => { + const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === "password-confirm"); + + assert(formFieldState !== undefined); + + return formFieldState.valueOrValues; + })(), + displayErrorsImmediately: formAction.displayErrorsImmediately + }); + } + + return; + case "focus lost": + if (formFieldState.hasLostFocusAtLeastOnce instanceof Array) { + const { fieldIndex } = formAction; + assert(fieldIndex !== undefined); + formFieldState.hasLostFocusAtLeastOnce[fieldIndex] = true; + return; + } + + formFieldState.hasLostFocusAtLeastOnce = true; + return; + } + assert>(false); + })(); + + return { ...state }; + }, initialState); + + const formState: FormState = useMemo( + () => ({ + formFieldStates: state.formFieldStates.map( + ({ errors, hasLostFocusAtLeastOnce: hasLostFocusAtLeastOnceOrArr, attribute, ...valueOrValuesWrap }) => ({ + displayableErrors: errors.filter(error => { + const hasLostFocusAtLeastOnce = + typeof hasLostFocusAtLeastOnceOrArr === "boolean" + ? hasLostFocusAtLeastOnceOrArr + : error.fieldIndex !== undefined + ? hasLostFocusAtLeastOnceOrArr[error.fieldIndex] + : hasLostFocusAtLeastOnceOrArr[hasLostFocusAtLeastOnceOrArr.length - 1]; + + switch (error.source.type) { + case "server": + return true; + case "other": + switch (error.source.rule) { + case "requiredField": + return hasLostFocusAtLeastOnce; + case "passwordConfirmMatchesPassword": + return hasLostFocusAtLeastOnce; + } + assert>(false); + case "passwordPolicy": + switch (error.source.name) { + case "length": + return hasLostFocusAtLeastOnce; + case "digits": + return hasLostFocusAtLeastOnce; + case "lowerCase": + return hasLostFocusAtLeastOnce; + case "upperCase": + return hasLostFocusAtLeastOnce; + case "specialChars": + return hasLostFocusAtLeastOnce; + case "notUsername": + return true; + case "notEmail": + return true; + } + assert>(false); + case "validator": + switch (error.source.name) { + case "length": + return hasLostFocusAtLeastOnce; + case "pattern": + return hasLostFocusAtLeastOnce; + case "email": + return hasLostFocusAtLeastOnce; + case "integer": + return hasLostFocusAtLeastOnce; + case "multivalued": + return hasLostFocusAtLeastOnce; + case "options": + return hasLostFocusAtLeastOnce; + } + assert>(false); + } + }), + attribute, + ...valueOrValuesWrap + }) + ), + isFormSubmittable: state.formFieldStates.every(({ errors }) => errors.length === 0) + }), + [state] + ); + + return { + formState, + dispatchFormAction + }; +} + +type KcContextLike_useGetErrors = { + messagesPerField: Pick; + passwordPolicies?: PasswordPolicies; +}; + +assert(); + +function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18n }) { + const { kcContext, i18n } = params; + + const { messagesPerField, passwordPolicies } = kcContext; + + const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n; + + const getErrors = useConstCallback( + (params: { + attributeName: string; + formFieldStates: { + attribute: Attribute; + valueOrValues: string | string[]; + }[]; + }): FormFieldError[] => { + const { attributeName, formFieldStates } = params; + + const formFieldState = formFieldStates.find(({ attribute }) => attribute.name === attributeName); + + assert(formFieldState !== undefined); + + const { attribute } = formFieldState; + + const valueOrValues = (() => { + let { valueOrValues } = formFieldState; + + unFormat_number: { + const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; + + if (!kcNumberUnFormat) { + break unFormat_number; + } + + if (valueOrValues instanceof Array) { + valueOrValues = valueOrValues.map(value => formatNumber(value, kcNumberUnFormat)); + } else { + valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); + } + } + + return valueOrValues; + })(); + + assert(attribute !== undefined); + + server_side_error: { + if (attribute.multivalued) { + const defaultValues = attribute.values?.length ? attribute.values : [""]; + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + if (JSON.stringify(defaultValues) !== JSON.stringify(values.slice(0, defaultValues.length))) { + break server_side_error; + } + } else { + const defaultValue = attribute.value ?? ""; + + assert(typeof valueOrValues === "string"); + + const value = valueOrValues; + + if (defaultValue !== value) { + break server_side_error; + } + } + + let doesErrorExist: boolean; + + try { + doesErrorExist = messagesPerField.existsError(attributeName); + } catch { + break server_side_error; + } + + if (!doesErrorExist) { + break server_side_error; + } + + const errorMessageStr = messagesPerField.get(attributeName); + + return [ + { + errorMessageStr, + errorMessage: ( + + ), + fieldIndex: undefined, + source: { + type: "server" + } + } + ]; + } + + handle_multi_valued_multi_fields: { + if (!attribute.multivalued) { + break handle_multi_valued_multi_fields; + } + + if (getIsMultivaluedSingleField({ attribute })) { + break handle_multi_valued_multi_fields; + } + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + const errors = values + .map((...[, index]) => { + const specificValueErrors = getErrors({ + attributeName, + formFieldStates: formFieldStates.map(formFieldState => { + if (formFieldState.attribute.name === attributeName) { + assert(formFieldState.valueOrValues instanceof Array); + return { + attribute: { + ...attribute, + annotations: { + ...attribute.annotations, + inputType: undefined + }, + multivalued: false + }, + valueOrValues: formFieldState.valueOrValues[index] + }; + } + + return formFieldState; + }) + }); + + return specificValueErrors + .filter(error => { + if (error.source.type === "other" && error.source.rule === "requiredField") { + return false; + } + + return true; + }) + .map( + (error): FormFieldError => ({ + ...error, + fieldIndex: index + }) + ); + }) + .reduce((acc, errors) => [...acc, ...errors], []); + + required_field: { + if (!attribute.required) { + break required_field; + } + + if (values.every(value => value !== "")) { + break required_field; + } + + const msgArgs = ["error-user-attribute-required"] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "other", + rule: "requiredField" + } + }); + } + + return errors; + } + + handle_multi_valued_single_field: { + if (!attribute.multivalued) { + break handle_multi_valued_single_field; + } + + if (!getIsMultivaluedSingleField({ attribute })) { + break handle_multi_valued_single_field; + } + + const validatorName = "multivalued"; + + const validator = attribute.validators[validatorName]; + + if (validator === undefined) { + return []; + } + + const { min: minStr } = validator; + + const min = minStr ? parseInt(`${minStr}`) : attribute.required ? 1 : 0; + + assert(!isNaN(min)); + + const { max: maxStr } = validator; + + const max = !maxStr ? Infinity : parseInt(`${maxStr}`); + + assert(!isNaN(max)); + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + if (min <= values.length && values.length <= max) { + return []; + } + + const msgArgs = ["error-invalid-multivalued-size", `${min}`, `${max}`] as const; + + return [ + { + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "validator", + name: validatorName + } + } + ]; + } + + assert(typeof valueOrValues === "string"); + + const value = valueOrValues; + + const errors: FormFieldError[] = []; + + check_password_policies: { + if (attributeName !== "password") { + break check_password_policies; + } + + if (passwordPolicies === undefined) { + break check_password_policies; + } + + check_password_policy_x: { + const policyName = "length"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minLength = policy; + + if (value.length >= minLength) { + break check_password_policy_x; + } + + const msgArgs = ["invalidPasswordMinLengthMessage", `${minLength}`] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "digits"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minNumberOfDigits = policy; + + if (value.split("").filter(char => !isNaN(parseInt(char))).length >= minNumberOfDigits) { + break check_password_policy_x; + } + + const msgArgs = ["invalidPasswordMinDigitsMessage", `${minNumberOfDigits}`] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "lowerCase"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minNumberOfLowerCaseChar = policy; + + if ( + value.split("").filter(char => char === char.toLowerCase() && char !== char.toUpperCase()).length >= minNumberOfLowerCaseChar + ) { + break check_password_policy_x; + } + + const msgArgs = ["invalidPasswordMinLowerCaseCharsMessage", `${minNumberOfLowerCaseChar}`] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "upperCase"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minNumberOfUpperCaseChar = policy; + + if ( + value.split("").filter(char => char === char.toUpperCase() && char !== char.toLowerCase()).length >= minNumberOfUpperCaseChar + ) { + break check_password_policy_x; + } + + const msgArgs = ["invalidPasswordMinUpperCaseCharsMessage", `${minNumberOfUpperCaseChar}`] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "specialChars"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minNumberOfSpecialChar = policy; + + if (value.split("").filter(char => !char.match(/[a-zA-Z0-9]/)).length >= minNumberOfSpecialChar) { + break check_password_policy_x; + } + + const msgArgs = ["invalidPasswordMinSpecialCharsMessage", `${minNumberOfSpecialChar}`] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "notUsername"; + + const notUsername = passwordPolicies[policyName]; + + if (!notUsername) { + break check_password_policy_x; + } + + const usernameFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "username"); + + if (!usernameFormFieldState) { + break check_password_policy_x; + } + + const usernameValue = (() => { + let { valueOrValues } = usernameFormFieldState; + + assert(typeof valueOrValues === "string"); + + unFormat_number: { + const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; + + if (!kcNumberUnFormat) { + break unFormat_number; + } + + valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); + } + + return valueOrValues; + })(); + + if (usernameValue === "") { + break check_password_policy_x; + } + + if (value !== usernameValue) { + break check_password_policy_x; + } + + const msgArgs = ["invalidPasswordNotUsernameMessage"] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "notEmail"; + + const notEmail = passwordPolicies[policyName]; + + if (!notEmail) { + break check_password_policy_x; + } + + const emailFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "email"); + + if (!emailFormFieldState) { + break check_password_policy_x; + } + + assert(typeof emailFormFieldState.valueOrValues === "string"); + + { + const emailValue = emailFormFieldState.valueOrValues; + + if (emailValue === "") { + break check_password_policy_x; + } + + if (value !== emailValue) { + break check_password_policy_x; + } + } + + const msgArgs = ["invalidPasswordNotEmailMessage"] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + } + + password_confirm_matches_password: { + if (attributeName !== "password-confirm") { + break password_confirm_matches_password; + } + + const passwordFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "password"); + + assert(passwordFormFieldState !== undefined); + + assert(typeof passwordFormFieldState.valueOrValues === "string"); + + { + const passwordValue = passwordFormFieldState.valueOrValues; + + if (value === passwordValue) { + break password_confirm_matches_password; + } + } + + const msgArgs = ["invalidPasswordConfirmMessage"] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "other", + rule: "passwordConfirmMatchesPassword" + } + }); + } + + const { validators } = attribute; + + required_field: { + if (!attribute.required) { + break required_field; + } + + if (value !== "") { + break required_field; + } + + const msgArgs = ["error-user-attribute-required"] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "other", + rule: "requiredField" + } + }); + } + + validator_x: { + const validatorName = "length"; + + const validator = validators[validatorName]; + + if (!validator) { + break validator_x; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + const source: FormFieldError.Source = { + type: "validator", + name: validatorName + }; + + if (max && value.length > parseInt(`${max}`)) { + const msgArgs = ["error-invalid-length-too-long", `${max}`] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source + }); + } + + if (min && value.length < parseInt(`${min}`)) { + const msgArgs = ["error-invalid-length-too-short", `${min}`] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source + }); + } + } + + validator_x: { + const validatorName = "pattern"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + if (new RegExp(pattern).test(value)) { + break validator_x; + } + + const msgArgs = [errorMessageKey ?? id("shouldMatchPattern"), pattern] as const; + + errors.push({ + errorMessage: {advancedMsg(...msgArgs)}, + errorMessageStr: advancedMsgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "validator", + name: validatorName + } + }); + } + + validator_x: { + { + const lastError = errors[errors.length - 1]; + if (lastError !== undefined && lastError.source.type === "validator" && lastError.source.name === "pattern") { + break validator_x; + } + } + + const validatorName = "email"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + const { "ignore.empty.value": ignoreEmptyValue = false } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + if (emailRegexp.test(value)) { + break validator_x; + } + + const msgArgs = [id("invalidEmailMessage")] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "validator", + name: validatorName + } + }); + } + + validator_x: { + const validatorName = "integer"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + const intValue = parseInt(value); + + const source: FormFieldError.Source = { + type: "validator", + name: validatorName + }; + + if (isNaN(intValue)) { + const msgArgs = ["mustBeAnInteger"] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source + }); + + break validator_x; + } + + if (max && intValue > parseInt(`${max}`)) { + const msgArgs = ["error-number-out-of-range-too-big", `${max}`] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source + }); + + break validator_x; + } + + if (min && intValue < parseInt(`${min}`)) { + const msgArgs = ["error-number-out-of-range-too-small", `${min}`] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source + }); + + break validator_x; + } + } + + validator_x: { + const validatorName = "options"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + if (value === "") { + break validator_x; + } + + if (validator.options.indexOf(value) >= 0) { + break validator_x; + } + + const msgArgs = [id("notAValidOption")] as const; + + errors.push({ + errorMessage: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "validator", + name: validatorName + } + }); + } + + //TODO: Implement missing validators. See Validators type definition. + + return errors; + } + ); + + return { getErrors }; +} + +function getIsMultivaluedSingleField(params: { attribute: Attribute }) { + const { attribute } = params; + + return attribute.annotations.inputType?.startsWith("multiselect") ?? false; +} + +export function getButtonToDisplayForMultivaluedAttributeField(params: { attribute: Attribute; values: string[]; fieldIndex: number }) { + const { attribute, values, fieldIndex } = params; + + const hasRemove = (() => { + if (values.length === 1) { + return false; + } + + const minCount = (() => { + const { multivalued } = attribute.validators; + + if (multivalued === undefined) { + return undefined; + } + + const minStr = multivalued.min; + + if (minStr === undefined) { + return undefined; + } + + return parseInt(`${minStr}`); + })(); + + if (minCount === undefined) { + return true; + } + + if (values.length === minCount) { + return false; + } + + return true; + })(); + + const hasAdd = (() => { + if (fieldIndex + 1 !== values.length) { + return false; + } + + const maxCount = (() => { + const { multivalued } = attribute.validators; + + if (multivalued === undefined) { + return undefined; + } + + const maxStr = multivalued.max; + + if (maxStr === undefined) { + return undefined; + } + + return parseInt(`${maxStr}`); + })(); + + if (maxCount === undefined) { + return true; + } + + return values.length !== maxCount; + })(); + + return { hasRemove, hasAdd }; +} diff --git a/src/login/lib/useUserProfileForm.tsx b/src/login/lib/useUserProfileForm.tsx index 3dfce1b9..3d997549 100644 --- a/src/login/lib/useUserProfileForm.tsx +++ b/src/login/lib/useUserProfileForm.tsx @@ -1,17 +1,7 @@ -import "keycloakify/tools/Array.prototype.every"; -import { useMemo, useReducer, useEffect, Fragment, type Dispatch } from "react"; -import { assert, type Equals } from "tsafe/assert"; -import { id } from "tsafe/id"; -import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions"; -import { kcSanitize } from "keycloakify/lib/kcSanitize"; -import { useConstCallback } from "keycloakify/tools/useConstCallback"; -import { emailRegexp } from "keycloakify/tools/emailRegExp"; -import { formatNumber } from "keycloakify/tools/formatNumber"; -import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import * as reactlessApi from "./getUserProfileApi"; import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext"; -import type { KcContext } from "../KcContext"; -import type { MessageKey_defaultSet } from "keycloakify/login/i18n"; -import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n"; +import { useEffect, useState, useMemo, Fragment } from "react"; +import { assert, type Equals } from "tsafe/assert"; import type { I18n } from "../i18n"; export type FormFieldError = { @@ -21,6 +11,13 @@ export type FormFieldError = { fieldIndex: number | undefined; }; +{ + type A = Omit; + type B = Omit; + + assert>(); +} + export namespace FormFieldError { export type Source = Source.Validator | Source.PasswordPolicy | Source.Server | Source.Other; @@ -44,17 +41,38 @@ export namespace FormFieldError { } } +{ + type A = FormFieldError.Source; + type B = reactlessApi.FormFieldError.Source; + + assert>(); +} + export type FormFieldState = { attribute: Attribute; displayableErrors: FormFieldError[]; valueOrValues: string | string[]; }; +{ + type A = Omit; + type B = Omit; + + assert>(); +} + export type FormState = { isFormSubmittable: boolean; formFieldStates: FormFieldState[]; }; +{ + type A = Omit; + type B = Omit; + + assert>(); +} + export type FormAction = | { action: "update"; @@ -69,1337 +87,78 @@ export type FormAction = fieldIndex: number | undefined; }; -export type KcContextLike = KcContextLike_i18n & - KcContextLike_useGetErrors & { - profile: { - attributesByName: Record; - html5DataAnnotations?: Record; - }; - passwordRequired?: boolean; - realm: { registrationEmailAsUsername: boolean }; - url: { - resourcesPath: string; - }; - }; +{ + type A = FormAction; + type B = reactlessApi.FormAction; -assert, { pageId: "register.ftl" }> extends KcContextLike ? true : false>(); + assert>(); +} -export type UseUserProfileFormParams = { +export type KcContextLike = reactlessApi.KcContextLike; + +export type I18nLike = Pick; + +export type ParamsOfUseUserProfileForm = { kcContext: KcContextLike; - i18n: I18n; doMakeUserConfirmPassword: boolean; + i18n: I18nLike; }; +{ + type A = Omit; + type B = reactlessApi.ParamsOfGetUserProfileApi; + + assert>(); +} + export type ReturnTypeOfUseUserProfileForm = { formState: FormState; - dispatchFormAction: Dispatch; + dispatchFormAction: (action: FormAction) => void; }; -namespace internal { - export type FormFieldState = { - attribute: Attribute; - errors: FormFieldError[]; - hasLostFocusAtLeastOnce: boolean | boolean[]; - valueOrValues: string | string[]; - }; +export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm { + const { doMakeUserConfirmPassword, i18n, kcContext } = params; - export type State = { - formFieldStates: FormFieldState[]; - }; -} - -export function useUserProfileForm(params: UseUserProfileFormParams): ReturnTypeOfUseUserProfileForm { - const { kcContext, i18n, doMakeUserConfirmPassword } = params; - - const { insertScriptTags } = useInsertScriptTags({ - componentOrHookName: "useUserProfileForm", - scriptTags: Object.keys(kcContext.profile?.html5DataAnnotations ?? {}) - .filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it. - .map(key => ({ - type: "module", - src: `${kcContext.url.resourcesPath}/js/${key}.js` - })) + const api = reactlessApi.getUserProfileApi({ + kcContext, + doMakeUserConfirmPassword }); + const [formState_reactless, setFormState_reactless] = useState(() => api.getFormState()); + useEffect(() => { - insertScriptTags(); - }, []); - - const { getErrors } = useGetErrors({ - kcContext, - i18n - }); - - const initialState = useMemo((): internal.State => { - // NOTE: We don't use te kcContext.profile.attributes directly because - // they don't includes the password and password confirm fields and we want to add them. - // We also want to apply some retro-compatibility and consistency patches. - const attributes: Attribute[] = (() => { - mock_user_profile_attributes_for_older_keycloak_versions: { - if ( - "profile" in kcContext && - "attributesByName" in kcContext.profile && - Object.keys(kcContext.profile.attributesByName).length !== 0 - ) { - break mock_user_profile_attributes_for_older_keycloak_versions; - } - - if ("register" in kcContext && kcContext.register instanceof Object && "formData" in kcContext.register) { - //NOTE: Handle legacy register.ftl page - return (["firstName", "lastName", "email", "username"] as const) - .filter(name => (name !== "username" ? true : !kcContext.realm.registrationEmailAsUsername)) - .map(name => - id({ - name: name, - displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), - required: true, - value: (kcContext.register as any).formData[name] ?? "", - html5DataAnnotations: {}, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: (() => { - switch (name) { - case "email": - return "email"; - case "username": - return "username"; - default: - return undefined; - } - })() - }) - ); - } - - if ("user" in kcContext && kcContext.user instanceof Object) { - //NOTE: Handle legacy login-update-profile.ftl - return (["username", "email", "firstName", "lastName"] as const) - .filter(name => (name !== "username" ? true : (kcContext.user as any).editUsernameAllowed)) - .map(name => - id({ - name: name, - displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), - required: true, - value: (kcContext as any).user[name] ?? "", - html5DataAnnotations: {}, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: (() => { - switch (name) { - case "email": - return "email"; - case "username": - return "username"; - default: - return undefined; - } - })() - }) - ); - } - - if ("email" in kcContext && kcContext.email instanceof Object) { - //NOTE: Handle legacy update-email.ftl - return [ - id({ - name: "email", - displayName: id<`\${${MessageKey_defaultSet}}`>(`\${email}`), - required: true, - value: (kcContext.email as any).value ?? "", - html5DataAnnotations: {}, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: "email" - }) - ]; - } - - assert(false, "Unable to mock user profile from the current kcContext"); - } - - return Object.values(kcContext.profile.attributesByName).map(structuredCloneButFunctions); - })(); - - // Retro-compatibility and consistency patches - attributes.forEach(attribute => { - patch_legacy_group: { - if (typeof attribute.group !== "string") { - break patch_legacy_group; - } - - const { group, groupDisplayHeader, groupDisplayDescription, groupAnnotations } = attribute as Attribute & { - group: string; - groupDisplayHeader?: string; - groupDisplayDescription?: string; - groupAnnotations: Record; - }; - - delete attribute.group; - // @ts-expect-error - delete attribute.groupDisplayHeader; - // @ts-expect-error - delete attribute.groupDisplayDescription; - // @ts-expect-error - delete attribute.groupAnnotations; - - if (group === "") { - break patch_legacy_group; - } - - attribute.group = { - name: group, - displayHeader: groupDisplayHeader, - displayDescription: groupDisplayDescription, - annotations: groupAnnotations, - html5DataAnnotations: {} - }; - } - - // Attributes with options rendered by default as select inputs - if (attribute.validators.options !== undefined && attribute.annotations.inputType === undefined) { - attribute.annotations.inputType = "select"; - } - - // Consistency patch on values/value property - { - if (getIsMultivaluedSingleField({ attribute })) { - attribute.multivalued = true; - } - - if (attribute.multivalued) { - attribute.values ??= attribute.value !== undefined ? [attribute.value] : []; - delete attribute.value; - } else { - attribute.value ??= attribute.values?.[0]; - delete attribute.values; - } - } + const { unsubscribe } = api.subscribeToFormState(() => { + setFormState_reactless(api.getFormState()); }); - add_password_and_password_confirm: { - if (!kcContext.passwordRequired) { - break add_password_and_password_confirm; - } + return () => unsubscribe(); + }, [api]); - attributes.forEach((attribute, i) => { - if (attribute.name !== (kcContext.realm.registrationEmailAsUsername ? "email" : "username")) { - // NOTE: We want to add password and password-confirm after the field that identifies the user. - // It's either email or username. - return; - } + const { advancedMsg, advancedMsgStr } = i18n; - attributes.splice( - i + 1, - 0, - { - name: "password", - displayName: id<`\${${MessageKey_defaultSet}}`>("${password}"), - required: true, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: "new-password", - html5DataAnnotations: {} - }, - { - name: "password-confirm", - displayName: id<`\${${MessageKey_defaultSet}}`>("${passwordConfirm}"), - required: true, - readOnly: false, - validators: {}, - annotations: {}, - html5DataAnnotations: {}, - autocomplete: "new-password" - } - ); - }); - } - - const initialFormFieldState: { - attribute: Attribute; - valueOrValues: string | string[]; - }[] = []; - - for (const attribute of attributes) { - handle_multi_valued_attribute: { - if (!attribute.multivalued) { - break handle_multi_valued_attribute; - } - - const values = attribute.values?.length ? attribute.values : [""]; - - apply_validator_min_range: { - if (getIsMultivaluedSingleField({ attribute })) { - break apply_validator_min_range; - } - - const validator = attribute.validators.multivalued; - - if (validator === undefined) { - break apply_validator_min_range; - } - - const { min: minStr } = validator; - - if (!minStr) { - break apply_validator_min_range; - } - - const min = parseInt(`${minStr}`); - - for (let index = values.length; index < min; index++) { - values.push(""); - } - } - - initialFormFieldState.push({ - attribute, - valueOrValues: values - }); - - continue; - } - - initialFormFieldState.push({ - attribute, - valueOrValues: attribute.value ?? "" - }); - } - - const initialState: internal.State = { - formFieldStates: initialFormFieldState.map(({ attribute, valueOrValues }) => ({ - attribute, - errors: getErrors({ - attributeName: attribute.name, - formFieldStates: initialFormFieldState - }), - hasLostFocusAtLeastOnce: - valueOrValues instanceof Array && !getIsMultivaluedSingleField({ attribute }) ? valueOrValues.map(() => false) : false, - valueOrValues: valueOrValues + const formState = useMemo( + (): FormState => ({ + isFormSubmittable: formState_reactless.isFormSubmittable, + formFieldStates: formState_reactless.formFieldStates.map(formFieldState_reactless => ({ + attribute: formFieldState_reactless.attribute, + valueOrValues: formFieldState_reactless.valueOrValues, + displayableErrors: formFieldState_reactless.displayableErrors.map((formFieldError_reactless, i) => ({ + errorMessage: ( + + {advancedMsg(...formFieldError_reactless.advancedMsgArgs)} + + ), + errorMessageStr: advancedMsgStr(...formFieldError_reactless.advancedMsgArgs), + source: formFieldError_reactless.source, + fieldIndex: formFieldError_reactless.fieldIndex + })) })) - }; - - return initialState; - }, []); - - const [state, dispatchFormAction] = useReducer(function reducer(state: internal.State, formAction: FormAction): internal.State { - const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === formAction.name); - - assert(formFieldState !== undefined); - - (() => { - switch (formAction.action) { - case "update": - formFieldState.valueOrValues = formAction.valueOrValues; - - apply_formatters: { - const { attribute } = formFieldState; - - const { kcNumberFormat } = attribute.html5DataAnnotations ?? {}; - - if (!kcNumberFormat) { - break apply_formatters; - } - - if (formFieldState.valueOrValues instanceof Array) { - formFieldState.valueOrValues = formFieldState.valueOrValues.map(value => formatNumber(value, kcNumberFormat)); - } else { - formFieldState.valueOrValues = formatNumber(formFieldState.valueOrValues, kcNumberFormat); - } - } - - formFieldState.errors = getErrors({ - attributeName: formAction.name, - formFieldStates: state.formFieldStates - }); - - simulate_focus_lost: { - const { displayErrorsImmediately = false } = formAction; - - if (!displayErrorsImmediately) { - break simulate_focus_lost; - } - - for (const fieldIndex of formAction.valueOrValues instanceof Array - ? formAction.valueOrValues.map((...[, index]) => index) - : [undefined]) { - state = reducer(state, { - action: "focus lost", - name: formAction.name, - fieldIndex - }); - } - } - - update_password_confirm: { - if (doMakeUserConfirmPassword) { - break update_password_confirm; - } - - if (formAction.name !== "password") { - break update_password_confirm; - } - - state = reducer(state, { - action: "update", - name: "password-confirm", - valueOrValues: formAction.valueOrValues, - displayErrorsImmediately: formAction.displayErrorsImmediately - }); - } - - trigger_password_confirm_validation_on_password_change: { - if (!doMakeUserConfirmPassword) { - break trigger_password_confirm_validation_on_password_change; - } - - if (formAction.name !== "password") { - break trigger_password_confirm_validation_on_password_change; - } - - state = reducer(state, { - action: "update", - name: "password-confirm", - valueOrValues: (() => { - const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === "password-confirm"); - - assert(formFieldState !== undefined); - - return formFieldState.valueOrValues; - })(), - displayErrorsImmediately: formAction.displayErrorsImmediately - }); - } - - return; - case "focus lost": - if (formFieldState.hasLostFocusAtLeastOnce instanceof Array) { - const { fieldIndex } = formAction; - assert(fieldIndex !== undefined); - formFieldState.hasLostFocusAtLeastOnce[fieldIndex] = true; - return; - } - - formFieldState.hasLostFocusAtLeastOnce = true; - return; - } - assert>(false); - })(); - - return { ...state }; - }, initialState); - - const formState: FormState = useMemo( - () => ({ - formFieldStates: state.formFieldStates.map( - ({ errors, hasLostFocusAtLeastOnce: hasLostFocusAtLeastOnceOrArr, attribute, ...valueOrValuesWrap }) => ({ - displayableErrors: errors.filter(error => { - const hasLostFocusAtLeastOnce = - typeof hasLostFocusAtLeastOnceOrArr === "boolean" - ? hasLostFocusAtLeastOnceOrArr - : error.fieldIndex !== undefined - ? hasLostFocusAtLeastOnceOrArr[error.fieldIndex] - : hasLostFocusAtLeastOnceOrArr[hasLostFocusAtLeastOnceOrArr.length - 1]; - - switch (error.source.type) { - case "server": - return true; - case "other": - switch (error.source.rule) { - case "requiredField": - return hasLostFocusAtLeastOnce; - case "passwordConfirmMatchesPassword": - return hasLostFocusAtLeastOnce; - } - assert>(false); - case "passwordPolicy": - switch (error.source.name) { - case "length": - return hasLostFocusAtLeastOnce; - case "digits": - return hasLostFocusAtLeastOnce; - case "lowerCase": - return hasLostFocusAtLeastOnce; - case "upperCase": - return hasLostFocusAtLeastOnce; - case "specialChars": - return hasLostFocusAtLeastOnce; - case "notUsername": - return true; - case "notEmail": - return true; - } - assert>(false); - case "validator": - switch (error.source.name) { - case "length": - return hasLostFocusAtLeastOnce; - case "pattern": - return hasLostFocusAtLeastOnce; - case "email": - return hasLostFocusAtLeastOnce; - case "integer": - return hasLostFocusAtLeastOnce; - case "multivalued": - return hasLostFocusAtLeastOnce; - case "options": - return hasLostFocusAtLeastOnce; - } - assert>(false); - } - }), - attribute, - ...valueOrValuesWrap - }) - ), - isFormSubmittable: state.formFieldStates.every(({ errors }) => errors.length === 0) }), - [state] + [formState_reactless] ); return { formState, - dispatchFormAction + dispatchFormAction: api.dispatchFormAction }; } - -type KcContextLike_useGetErrors = KcContextLike_i18n & { - messagesPerField: Pick; - passwordPolicies?: PasswordPolicies; -}; - -assert(); - -function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18n }) { - const { kcContext, i18n } = params; - - const { messagesPerField, passwordPolicies } = kcContext; - - const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n; - - const getErrors = useConstCallback( - (params: { - attributeName: string; - formFieldStates: { - attribute: Attribute; - valueOrValues: string | string[]; - }[]; - }): FormFieldError[] => { - const { attributeName, formFieldStates } = params; - - const formFieldState = formFieldStates.find(({ attribute }) => attribute.name === attributeName); - - assert(formFieldState !== undefined); - - const { attribute } = formFieldState; - - const valueOrValues = (() => { - let { valueOrValues } = formFieldState; - - unFormat_number: { - const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; - - if (!kcNumberUnFormat) { - break unFormat_number; - } - - if (valueOrValues instanceof Array) { - valueOrValues = valueOrValues.map(value => formatNumber(value, kcNumberUnFormat)); - } else { - valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); - } - } - - return valueOrValues; - })(); - - assert(attribute !== undefined); - - server_side_error: { - if (attribute.multivalued) { - const defaultValues = attribute.values?.length ? attribute.values : [""]; - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - if (JSON.stringify(defaultValues) !== JSON.stringify(values.slice(0, defaultValues.length))) { - break server_side_error; - } - } else { - const defaultValue = attribute.value ?? ""; - - assert(typeof valueOrValues === "string"); - - const value = valueOrValues; - - if (defaultValue !== value) { - break server_side_error; - } - } - - let doesErrorExist: boolean; - - try { - doesErrorExist = messagesPerField.existsError(attributeName); - } catch { - break server_side_error; - } - - if (!doesErrorExist) { - break server_side_error; - } - - const errorMessageStr = messagesPerField.get(attributeName); - - return [ - { - errorMessageStr, - errorMessage: ( - - ), - fieldIndex: undefined, - source: { - type: "server" - } - } - ]; - } - - handle_multi_valued_multi_fields: { - if (!attribute.multivalued) { - break handle_multi_valued_multi_fields; - } - - if (getIsMultivaluedSingleField({ attribute })) { - break handle_multi_valued_multi_fields; - } - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - const errors = values - .map((...[, index]) => { - const specificValueErrors = getErrors({ - attributeName, - formFieldStates: formFieldStates.map(formFieldState => { - if (formFieldState.attribute.name === attributeName) { - assert(formFieldState.valueOrValues instanceof Array); - return { - attribute: { - ...attribute, - annotations: { - ...attribute.annotations, - inputType: undefined - }, - multivalued: false - }, - valueOrValues: formFieldState.valueOrValues[index] - }; - } - - return formFieldState; - }) - }); - - return specificValueErrors - .filter(error => { - if (error.source.type === "other" && error.source.rule === "requiredField") { - return false; - } - - return true; - }) - .map( - (error): FormFieldError => ({ - ...error, - fieldIndex: index - }) - ); - }) - .reduce((acc, errors) => [...acc, ...errors], []); - - required_field: { - if (!attribute.required) { - break required_field; - } - - if (values.every(value => value !== "")) { - break required_field; - } - - const msgArgs = ["error-user-attribute-required"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "other", - rule: "requiredField" - } - }); - } - - return errors; - } - - handle_multi_valued_single_field: { - if (!attribute.multivalued) { - break handle_multi_valued_single_field; - } - - if (!getIsMultivaluedSingleField({ attribute })) { - break handle_multi_valued_single_field; - } - - const validatorName = "multivalued"; - - const validator = attribute.validators[validatorName]; - - if (validator === undefined) { - return []; - } - - const { min: minStr } = validator; - - const min = minStr ? parseInt(`${minStr}`) : attribute.required ? 1 : 0; - - assert(!isNaN(min)); - - const { max: maxStr } = validator; - - const max = !maxStr ? Infinity : parseInt(`${maxStr}`); - - assert(!isNaN(max)); - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - if (min <= values.length && values.length <= max) { - return []; - } - - const msgArgs = ["error-invalid-multivalued-size", `${min}`, `${max}`] as const; - - return [ - { - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "validator", - name: validatorName - } - } - ]; - } - - assert(typeof valueOrValues === "string"); - - const value = valueOrValues; - - const errors: FormFieldError[] = []; - - check_password_policies: { - if (attributeName !== "password") { - break check_password_policies; - } - - if (passwordPolicies === undefined) { - break check_password_policies; - } - - check_password_policy_x: { - const policyName = "length"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minLength = policy; - - if (value.length >= minLength) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinLengthMessage", `${minLength}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "digits"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minNumberOfDigits = policy; - - if (value.split("").filter(char => !isNaN(parseInt(char))).length >= minNumberOfDigits) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinDigitsMessage", `${minNumberOfDigits}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "lowerCase"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minNumberOfLowerCaseChar = policy; - - if ( - value.split("").filter(char => char === char.toLowerCase() && char !== char.toUpperCase()).length >= minNumberOfLowerCaseChar - ) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinLowerCaseCharsMessage", `${minNumberOfLowerCaseChar}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "upperCase"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minNumberOfUpperCaseChar = policy; - - if ( - value.split("").filter(char => char === char.toUpperCase() && char !== char.toLowerCase()).length >= minNumberOfUpperCaseChar - ) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinUpperCaseCharsMessage", `${minNumberOfUpperCaseChar}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "specialChars"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minNumberOfSpecialChar = policy; - - if (value.split("").filter(char => !char.match(/[a-zA-Z0-9]/)).length >= minNumberOfSpecialChar) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinSpecialCharsMessage", `${minNumberOfSpecialChar}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "notUsername"; - - const notUsername = passwordPolicies[policyName]; - - if (!notUsername) { - break check_password_policy_x; - } - - const usernameFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "username"); - - if (!usernameFormFieldState) { - break check_password_policy_x; - } - - const usernameValue = (() => { - let { valueOrValues } = usernameFormFieldState; - - assert(typeof valueOrValues === "string"); - - unFormat_number: { - const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; - - if (!kcNumberUnFormat) { - break unFormat_number; - } - - valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); - } - - return valueOrValues; - })(); - - if (usernameValue === "") { - break check_password_policy_x; - } - - if (value !== usernameValue) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordNotUsernameMessage"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "notEmail"; - - const notEmail = passwordPolicies[policyName]; - - if (!notEmail) { - break check_password_policy_x; - } - - const emailFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "email"); - - if (!emailFormFieldState) { - break check_password_policy_x; - } - - assert(typeof emailFormFieldState.valueOrValues === "string"); - - { - const emailValue = emailFormFieldState.valueOrValues; - - if (emailValue === "") { - break check_password_policy_x; - } - - if (value !== emailValue) { - break check_password_policy_x; - } - } - - const msgArgs = ["invalidPasswordNotEmailMessage"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - } - - password_confirm_matches_password: { - if (attributeName !== "password-confirm") { - break password_confirm_matches_password; - } - - const passwordFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "password"); - - assert(passwordFormFieldState !== undefined); - - assert(typeof passwordFormFieldState.valueOrValues === "string"); - - { - const passwordValue = passwordFormFieldState.valueOrValues; - - if (value === passwordValue) { - break password_confirm_matches_password; - } - } - - const msgArgs = ["invalidPasswordConfirmMessage"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "other", - rule: "passwordConfirmMatchesPassword" - } - }); - } - - const { validators } = attribute; - - required_field: { - if (!attribute.required) { - break required_field; - } - - if (value !== "") { - break required_field; - } - - const msgArgs = ["error-user-attribute-required"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "other", - rule: "requiredField" - } - }); - } - - validator_x: { - const validatorName = "length"; - - const validator = validators[validatorName]; - - if (!validator) { - break validator_x; - } - - const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; - - if (ignoreEmptyValue && value === "") { - break validator_x; - } - - const source: FormFieldError.Source = { - type: "validator", - name: validatorName - }; - - if (max && value.length > parseInt(`${max}`)) { - const msgArgs = ["error-invalid-length-too-long", `${max}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - } - - if (min && value.length < parseInt(`${min}`)) { - const msgArgs = ["error-invalid-length-too-short", `${min}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - } - } - - validator_x: { - const validatorName = "pattern"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break validator_x; - } - - const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator; - - if (ignoreEmptyValue && value === "") { - break validator_x; - } - - if (new RegExp(pattern).test(value)) { - break validator_x; - } - - const msgArgs = [errorMessageKey ?? id("shouldMatchPattern"), pattern] as const; - - errors.push({ - errorMessage: {advancedMsg(...msgArgs)}, - errorMessageStr: advancedMsgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "validator", - name: validatorName - } - }); - } - - validator_x: { - { - const lastError = errors[errors.length - 1]; - if (lastError !== undefined && lastError.source.type === "validator" && lastError.source.name === "pattern") { - break validator_x; - } - } - - const validatorName = "email"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break validator_x; - } - - const { "ignore.empty.value": ignoreEmptyValue = false } = validator; - - if (ignoreEmptyValue && value === "") { - break validator_x; - } - - if (emailRegexp.test(value)) { - break validator_x; - } - - const msgArgs = [id("invalidEmailMessage")] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "validator", - name: validatorName - } - }); - } - - validator_x: { - const validatorName = "integer"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break validator_x; - } - - const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; - - if (ignoreEmptyValue && value === "") { - break validator_x; - } - - const intValue = parseInt(value); - - const source: FormFieldError.Source = { - type: "validator", - name: validatorName - }; - - if (isNaN(intValue)) { - const msgArgs = ["mustBeAnInteger"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - - break validator_x; - } - - if (max && intValue > parseInt(`${max}`)) { - const msgArgs = ["error-number-out-of-range-too-big", `${max}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - - break validator_x; - } - - if (min && intValue < parseInt(`${min}`)) { - const msgArgs = ["error-number-out-of-range-too-small", `${min}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - - break validator_x; - } - } - - validator_x: { - const validatorName = "options"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break validator_x; - } - - if (value === "") { - break validator_x; - } - - if (validator.options.indexOf(value) >= 0) { - break validator_x; - } - - const msgArgs = [id("notAValidOption")] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "validator", - name: validatorName - } - }); - } - - //TODO: Implement missing validators. See Validators type definition. - - return errors; - } - ); - - return { getErrors }; -} - -function getIsMultivaluedSingleField(params: { attribute: Attribute }) { - const { attribute } = params; - - return attribute.annotations.inputType?.startsWith("multiselect") ?? false; -} - -export function getButtonToDisplayForMultivaluedAttributeField(params: { attribute: Attribute; values: string[]; fieldIndex: number }) { - const { attribute, values, fieldIndex } = params; - - const hasRemove = (() => { - if (values.length === 1) { - return false; - } - - const minCount = (() => { - const { multivalued } = attribute.validators; - - if (multivalued === undefined) { - return undefined; - } - - const minStr = multivalued.min; - - if (minStr === undefined) { - return undefined; - } - - return parseInt(`${minStr}`); - })(); - - if (minCount === undefined) { - return true; - } - - if (values.length === minCount) { - return false; - } - - return true; - })(); - - const hasAdd = (() => { - if (fieldIndex + 1 !== values.length) { - return false; - } - - const maxCount = (() => { - const { multivalued } = attribute.validators; - - if (multivalued === undefined) { - return undefined; - } - - const maxStr = multivalued.max; - - if (maxStr === undefined) { - return undefined; - } - - return parseInt(`${maxStr}`); - })(); - - if (maxCount === undefined) { - return true; - } - - return values.length !== maxCount; - })(); - - return { hasRemove, hasAdd }; -} From 36dd3241392b6aacbbda8b84373419a3a07b24bb Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 19 Oct 2024 10:18:22 +0200 Subject: [PATCH 02/82] complete decoupling of user profile form validation logic --- src/login/lib/getUserProfileApi.ts | 133 -- .../getUserProfileApi/getUserProfileApi.ts | 1561 +++++++++++++++++ src/login/lib/getUserProfileApi/index.ts | 1 + .../lib/getUserProfileApi/kcNumberUnFormat.ts | 109 ++ src/login/lib/useUserProfileForm copy.tsx | 1403 --------------- src/login/lib/useUserProfileForm.tsx | 3 +- 6 files changed, 1673 insertions(+), 1537 deletions(-) delete mode 100644 src/login/lib/getUserProfileApi.ts create mode 100644 src/login/lib/getUserProfileApi/getUserProfileApi.ts create mode 100644 src/login/lib/getUserProfileApi/index.ts create mode 100644 src/login/lib/getUserProfileApi/kcNumberUnFormat.ts delete mode 100644 src/login/lib/useUserProfileForm copy.tsx diff --git a/src/login/lib/getUserProfileApi.ts b/src/login/lib/getUserProfileApi.ts deleted file mode 100644 index 5cc88463..00000000 --- a/src/login/lib/getUserProfileApi.ts +++ /dev/null @@ -1,133 +0,0 @@ -import "keycloakify/tools/Array.prototype.every"; -import { assert } from "tsafe/assert"; -import type { - PasswordPolicies, - Attribute, - Validators -} from "keycloakify/login/KcContext"; -import type { KcContext } from "../KcContext"; -import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n"; - -export type FormFieldError = { - advancedMsgArgs: [string, ...string[]]; - source: FormFieldError.Source; - fieldIndex: number | undefined; -}; - -export namespace FormFieldError { - export type Source = - | Source.Validator - | Source.PasswordPolicy - | Source.Server - | Source.Other; - - export namespace Source { - export type Validator = { - type: "validator"; - name: keyof Validators; - }; - export type PasswordPolicy = { - type: "passwordPolicy"; - name: keyof PasswordPolicies; - }; - export type Server = { - type: "server"; - }; - - export type Other = { - type: "other"; - rule: "passwordConfirmMatchesPassword" | "requiredField"; - }; - } -} - -export type FormFieldState = { - attribute: Attribute; - displayableErrors: FormFieldError[]; - valueOrValues: string | string[]; -}; - -export type FormState = { - isFormSubmittable: boolean; - formFieldStates: FormFieldState[]; -}; - -export type FormAction = - | { - action: "update"; - name: string; - valueOrValues: string | string[]; - /** Default false */ - displayErrorsImmediately?: boolean; - } - | { - action: "focus lost"; - name: string; - fieldIndex: number | undefined; - }; - -export type KcContextLike = KcContextLike_i18n & - KcContextLike_useGetErrors & { - profile: { - attributesByName: Record; - html5DataAnnotations?: Record; - }; - passwordRequired?: boolean; - realm: { registrationEmailAsUsername: boolean }; - url: { - resourcesPath: string; - }; - }; - -type KcContextLike_useGetErrors = KcContextLike_i18n & { - messagesPerField: Pick; - passwordPolicies?: PasswordPolicies; -}; - -assert< - Extract< - Extract, - { pageId: "register.ftl" } - > extends KcContextLike - ? true - : false ->(); - -export type UserProfileApi = { - getFormState: () => FormState; - subscribeToFormState: (callback: () => void) => { unsubscribe: () => void }; - dispatchFormAction: (action: FormAction) => void; -}; - -const cachedUserProfileApiByKcContext = new WeakMap(); - -export type ParamsOfGetUserProfileApi = { - kcContext: KcContextLike; - doMakeUserConfirmPassword: boolean; -}; - -export function getUserProfileApi(params: ParamsOfGetUserProfileApi): UserProfileApi { - const { kcContext } = params; - - use_cache: { - const userProfileApi_cache = cachedUserProfileApiByKcContext.get(kcContext); - - if (userProfileApi_cache === undefined) { - break use_cache; - } - - return userProfileApi_cache; - } - - const userProfileApi = getUserProfileApi_noCache(params); - - cachedUserProfileApiByKcContext.set(kcContext, userProfileApi); - - return userProfileApi; -} - -export function getUserProfileApi_noCache( - params: ParamsOfGetUserProfileApi -): UserProfileApi { - return null as any; -} diff --git a/src/login/lib/getUserProfileApi/getUserProfileApi.ts b/src/login/lib/getUserProfileApi/getUserProfileApi.ts new file mode 100644 index 00000000..31230ad3 --- /dev/null +++ b/src/login/lib/getUserProfileApi/getUserProfileApi.ts @@ -0,0 +1,1561 @@ +import "keycloakify/tools/Array.prototype.every"; +import { assert, type Equals } from "tsafe/assert"; +import type { + PasswordPolicies, + Attribute, + Validators +} from "keycloakify/login/KcContext"; +import type { KcContext } from "../../KcContext"; +import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n"; +import { formatNumber } from "keycloakify/tools/formatNumber"; +import type { MessageKey_defaultSet } from "keycloakify/login/i18n"; +import { emailRegexp } from "keycloakify/tools/emailRegExp"; +import { unFormatNumberOnSubmit } from "./kcNumberUnFormat"; +import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions"; +import { id } from "tsafe/id"; + +export type FormFieldError = { + advancedMsgArgs: readonly [string, ...string[]]; + source: FormFieldError.Source; + fieldIndex: number | undefined; +}; + +export namespace FormFieldError { + export type Source = + | Source.Validator + | Source.PasswordPolicy + | Source.Server + | Source.Other; + + export namespace Source { + export type Validator = { + type: "validator"; + name: keyof Validators; + }; + export type PasswordPolicy = { + type: "passwordPolicy"; + name: keyof PasswordPolicies; + }; + export type Server = { + type: "server"; + }; + + export type Other = { + type: "other"; + rule: "passwordConfirmMatchesPassword" | "requiredField"; + }; + } +} + +export type FormFieldState = { + attribute: Attribute; + displayableErrors: FormFieldError[]; + valueOrValues: string | string[]; +}; + +export type FormState = { + isFormSubmittable: boolean; + formFieldStates: FormFieldState[]; +}; + +export type FormAction = + | { + action: "update"; + name: string; + valueOrValues: string | string[]; + /** Default false */ + displayErrorsImmediately?: boolean; + } + | { + action: "focus lost"; + name: string; + fieldIndex: number | undefined; + }; + +export type KcContextLike = KcContextLike_i18n & + KcContextLike_useGetErrors & { + profile: { + attributesByName: Record; + html5DataAnnotations?: Record; + }; + passwordRequired?: boolean; + realm: { registrationEmailAsUsername: boolean }; + url: { + resourcesPath: string; + }; + }; + +type KcContextLike_useGetErrors = KcContextLike_i18n & { + messagesPerField: Pick; + passwordPolicies?: PasswordPolicies; +}; + +assert< + Extract< + Extract, + { pageId: "register.ftl" } + > extends KcContextLike + ? true + : false +>(); + +export type UserProfileApi = { + getFormState: () => FormState; + subscribeToFormState: (callback: () => void) => { unsubscribe: () => void }; + dispatchFormAction: (action: FormAction) => void; +}; + +const cachedUserProfileApiByKcContext = new WeakMap(); + +export type ParamsOfGetUserProfileApi = { + kcContext: KcContextLike; + doMakeUserConfirmPassword: boolean; +}; + +export function getUserProfileApi(params: ParamsOfGetUserProfileApi): UserProfileApi { + const { kcContext } = params; + + use_cache: { + const userProfileApi_cache = cachedUserProfileApiByKcContext.get(kcContext); + + if (userProfileApi_cache === undefined) { + break use_cache; + } + + return userProfileApi_cache; + } + + const userProfileApi = getUserProfileApi_noCache(params); + + cachedUserProfileApiByKcContext.set(kcContext, userProfileApi); + + return userProfileApi; +} + +namespace internal { + export type FormFieldState = { + attribute: Attribute; + errors: FormFieldError[]; + hasLostFocusAtLeastOnce: boolean | boolean[]; + valueOrValues: string | string[]; + }; + + export type State = { + formFieldStates: FormFieldState[]; + }; +} + +export function getUserProfileApi_noCache( + params: ParamsOfGetUserProfileApi +): UserProfileApi { + const { kcContext, doMakeUserConfirmPassword } = params; + + unFormatNumberOnSubmit(); + + let state: internal.State = getInitialState({ kcContext }); + const callbacks = new Set<() => void>(); + + return { + dispatchFormAction: action => { + state = reducer({ action, kcContext, doMakeUserConfirmPassword, state }); + + callbacks.forEach(callback => callback()); + }, + getFormState: () => formStateSelector({ state }), + subscribeToFormState: callback => { + callbacks.add(callback); + return { + unsubscribe: () => { + callbacks.delete(callback); + } + }; + } + }; +} + +function getInitialState(params: { kcContext: KcContextLike }): internal.State { + const { kcContext } = params; + + const { getErrors } = createGetErrors({ kcContext }); + + // NOTE: We don't use te kcContext.profile.attributes directly because + // they don't includes the password and password confirm fields and we want to add them. + // We also want to apply some retro-compatibility and consistency patches. + const attributes: Attribute[] = (() => { + mock_user_profile_attributes_for_older_keycloak_versions: { + if ( + "profile" in kcContext && + "attributesByName" in kcContext.profile && + Object.keys(kcContext.profile.attributesByName).length !== 0 + ) { + break mock_user_profile_attributes_for_older_keycloak_versions; + } + + if ( + "register" in kcContext && + kcContext.register instanceof Object && + "formData" in kcContext.register + ) { + //NOTE: Handle legacy register.ftl page + return (["firstName", "lastName", "email", "username"] as const) + .filter(name => + name !== "username" + ? true + : !kcContext.realm.registrationEmailAsUsername + ) + .map(name => + id({ + name: name, + displayName: id<`\${${MessageKey_defaultSet}}`>( + `\${${name}}` + ), + required: true, + value: (kcContext.register as any).formData[name] ?? "", + html5DataAnnotations: {}, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: (() => { + switch (name) { + case "email": + return "email"; + case "username": + return "username"; + default: + return undefined; + } + })() + }) + ); + } + + if ("user" in kcContext && kcContext.user instanceof Object) { + //NOTE: Handle legacy login-update-profile.ftl + return (["username", "email", "firstName", "lastName"] as const) + .filter(name => + name !== "username" + ? true + : (kcContext.user as any).editUsernameAllowed + ) + .map(name => + id({ + name: name, + displayName: id<`\${${MessageKey_defaultSet}}`>( + `\${${name}}` + ), + required: true, + value: (kcContext as any).user[name] ?? "", + html5DataAnnotations: {}, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: (() => { + switch (name) { + case "email": + return "email"; + case "username": + return "username"; + default: + return undefined; + } + })() + }) + ); + } + + if ("email" in kcContext && kcContext.email instanceof Object) { + //NOTE: Handle legacy update-email.ftl + return [ + id({ + name: "email", + displayName: id<`\${${MessageKey_defaultSet}}`>(`\${email}`), + required: true, + value: (kcContext.email as any).value ?? "", + html5DataAnnotations: {}, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: "email" + }) + ]; + } + + assert(false, "Unable to mock user profile from the current kcContext"); + } + + return Object.values(kcContext.profile.attributesByName).map( + structuredCloneButFunctions + ); + })(); + + // Retro-compatibility and consistency patches + attributes.forEach(attribute => { + patch_legacy_group: { + if (typeof attribute.group !== "string") { + break patch_legacy_group; + } + + const { + group, + groupDisplayHeader, + groupDisplayDescription, + groupAnnotations + } = attribute as Attribute & { + group: string; + groupDisplayHeader?: string; + groupDisplayDescription?: string; + groupAnnotations: Record; + }; + + delete attribute.group; + // @ts-expect-error + delete attribute.groupDisplayHeader; + // @ts-expect-error + delete attribute.groupDisplayDescription; + // @ts-expect-error + delete attribute.groupAnnotations; + + if (group === "") { + break patch_legacy_group; + } + + attribute.group = { + name: group, + displayHeader: groupDisplayHeader, + displayDescription: groupDisplayDescription, + annotations: groupAnnotations, + html5DataAnnotations: {} + }; + } + + // Attributes with options rendered by default as select inputs + if ( + attribute.validators.options !== undefined && + attribute.annotations.inputType === undefined + ) { + attribute.annotations.inputType = "select"; + } + + // Consistency patch on values/value property + { + if (getIsMultivaluedSingleField({ attribute })) { + attribute.multivalued = true; + } + + if (attribute.multivalued) { + attribute.values ??= + attribute.value !== undefined ? [attribute.value] : []; + delete attribute.value; + } else { + attribute.value ??= attribute.values?.[0]; + delete attribute.values; + } + } + }); + + add_password_and_password_confirm: { + if (!kcContext.passwordRequired) { + break add_password_and_password_confirm; + } + + attributes.forEach((attribute, i) => { + if ( + attribute.name !== + (kcContext.realm.registrationEmailAsUsername ? "email" : "username") + ) { + // NOTE: We want to add password and password-confirm after the field that identifies the user. + // It's either email or username. + return; + } + + attributes.splice( + i + 1, + 0, + { + name: "password", + displayName: id<`\${${MessageKey_defaultSet}}`>("${password}"), + required: true, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: "new-password", + html5DataAnnotations: {} + }, + { + name: "password-confirm", + displayName: id<`\${${MessageKey_defaultSet}}`>("${passwordConfirm}"), + required: true, + readOnly: false, + validators: {}, + annotations: {}, + html5DataAnnotations: {}, + autocomplete: "new-password" + } + ); + }); + } + + const initialFormFieldState: { + attribute: Attribute; + valueOrValues: string | string[]; + }[] = []; + + for (const attribute of attributes) { + handle_multi_valued_attribute: { + if (!attribute.multivalued) { + break handle_multi_valued_attribute; + } + + const values = attribute.values?.length ? attribute.values : [""]; + + apply_validator_min_range: { + if (getIsMultivaluedSingleField({ attribute })) { + break apply_validator_min_range; + } + + const validator = attribute.validators.multivalued; + + if (validator === undefined) { + break apply_validator_min_range; + } + + const { min: minStr } = validator; + + if (!minStr) { + break apply_validator_min_range; + } + + const min = parseInt(`${minStr}`); + + for (let index = values.length; index < min; index++) { + values.push(""); + } + } + + initialFormFieldState.push({ + attribute, + valueOrValues: values + }); + + continue; + } + + initialFormFieldState.push({ + attribute, + valueOrValues: attribute.value ?? "" + }); + } + + const initialState: internal.State = { + formFieldStates: initialFormFieldState.map(({ attribute, valueOrValues }) => ({ + attribute, + errors: getErrors({ + attributeName: attribute.name, + formFieldStates: initialFormFieldState + }), + hasLostFocusAtLeastOnce: + valueOrValues instanceof Array && + !getIsMultivaluedSingleField({ attribute }) + ? valueOrValues.map(() => false) + : false, + valueOrValues: valueOrValues + })) + }; + + return initialState; +} + +const formStateByState = new WeakMap(); + +function formStateSelector(params: { state: internal.State }): FormState { + const { state } = params; + + use_memoized_value: { + const formState = formStateByState.get(state); + if (formState === undefined) { + break use_memoized_value; + } + return formState; + } + + return { + formFieldStates: state.formFieldStates.map( + ({ + errors, + hasLostFocusAtLeastOnce: hasLostFocusAtLeastOnceOrArr, + attribute, + ...valueOrValuesWrap + }) => ({ + displayableErrors: errors.filter(error => { + const hasLostFocusAtLeastOnce = + typeof hasLostFocusAtLeastOnceOrArr === "boolean" + ? hasLostFocusAtLeastOnceOrArr + : error.fieldIndex !== undefined + ? hasLostFocusAtLeastOnceOrArr[error.fieldIndex] + : hasLostFocusAtLeastOnceOrArr[ + hasLostFocusAtLeastOnceOrArr.length - 1 + ]; + + switch (error.source.type) { + case "server": + return true; + case "other": + switch (error.source.rule) { + case "requiredField": + return hasLostFocusAtLeastOnce; + case "passwordConfirmMatchesPassword": + return hasLostFocusAtLeastOnce; + } + assert>(false); + case "passwordPolicy": + switch (error.source.name) { + case "length": + return hasLostFocusAtLeastOnce; + case "digits": + return hasLostFocusAtLeastOnce; + case "lowerCase": + return hasLostFocusAtLeastOnce; + case "upperCase": + return hasLostFocusAtLeastOnce; + case "specialChars": + return hasLostFocusAtLeastOnce; + case "notUsername": + return true; + case "notEmail": + return true; + } + assert>(false); + case "validator": + switch (error.source.name) { + case "length": + return hasLostFocusAtLeastOnce; + case "pattern": + return hasLostFocusAtLeastOnce; + case "email": + return hasLostFocusAtLeastOnce; + case "integer": + return hasLostFocusAtLeastOnce; + case "multivalued": + return hasLostFocusAtLeastOnce; + case "options": + return hasLostFocusAtLeastOnce; + } + assert>(false); + } + }), + attribute, + ...valueOrValuesWrap + }) + ), + isFormSubmittable: state.formFieldStates.every( + ({ errors }) => errors.length === 0 + ) + }; +} + +function reducer(params: { + state: internal.State; + kcContext: KcContextLike; + doMakeUserConfirmPassword: boolean; + action: FormAction; +}): internal.State { + const { kcContext, doMakeUserConfirmPassword, action } = params; + let { state } = params; + + const { getErrors } = createGetErrors({ kcContext }); + + const formFieldState = state.formFieldStates.find( + ({ attribute }) => attribute.name === action.name + ); + + assert(formFieldState !== undefined); + + (() => { + switch (action.action) { + case "update": + formFieldState.valueOrValues = action.valueOrValues; + + apply_formatters: { + const { attribute } = formFieldState; + + const { kcNumberFormat } = attribute.html5DataAnnotations ?? {}; + + if (!kcNumberFormat) { + break apply_formatters; + } + + if (formFieldState.valueOrValues instanceof Array) { + formFieldState.valueOrValues = formFieldState.valueOrValues.map( + value => formatNumber(value, kcNumberFormat) + ); + } else { + formFieldState.valueOrValues = formatNumber( + formFieldState.valueOrValues, + kcNumberFormat + ); + } + } + + formFieldState.errors = getErrors({ + attributeName: action.name, + formFieldStates: state.formFieldStates + }); + + simulate_focus_lost: { + const { displayErrorsImmediately = false } = action; + + if (!displayErrorsImmediately) { + break simulate_focus_lost; + } + + for (const fieldIndex of action.valueOrValues instanceof Array + ? action.valueOrValues.map((...[, index]) => index) + : [undefined]) { + state = reducer({ + state, + kcContext, + doMakeUserConfirmPassword, + action: { + action: "focus lost", + name: action.name, + fieldIndex + } + }); + } + } + + update_password_confirm: { + if (doMakeUserConfirmPassword) { + break update_password_confirm; + } + + if (action.name !== "password") { + break update_password_confirm; + } + + state = reducer({ + state, + kcContext, + doMakeUserConfirmPassword, + action: { + action: "update", + name: "password-confirm", + valueOrValues: action.valueOrValues, + displayErrorsImmediately: action.displayErrorsImmediately + } + }); + } + + trigger_password_confirm_validation_on_password_change: { + if (!doMakeUserConfirmPassword) { + break trigger_password_confirm_validation_on_password_change; + } + + if (action.name !== "password") { + break trigger_password_confirm_validation_on_password_change; + } + + state = reducer({ + state, + kcContext, + doMakeUserConfirmPassword, + action: { + action: "update", + name: "password-confirm", + valueOrValues: (() => { + const formFieldState = state.formFieldStates.find( + ({ attribute }) => + attribute.name === "password-confirm" + ); + + assert(formFieldState !== undefined); + + return formFieldState.valueOrValues; + })(), + displayErrorsImmediately: action.displayErrorsImmediately + } + }); + } + + return; + case "focus lost": + if (formFieldState.hasLostFocusAtLeastOnce instanceof Array) { + const { fieldIndex } = action; + assert(fieldIndex !== undefined); + formFieldState.hasLostFocusAtLeastOnce[fieldIndex] = true; + return; + } + + formFieldState.hasLostFocusAtLeastOnce = true; + return; + } + assert>(false); + })(); + + return { ...state }; +} + +function createGetErrors(params: { kcContext: KcContextLike_useGetErrors }) { + const { kcContext } = params; + + const { messagesPerField, passwordPolicies } = kcContext; + + function getErrors(params: { + attributeName: string; + formFieldStates: { + attribute: Attribute; + valueOrValues: string | string[]; + }[]; + }): FormFieldError[] { + const { attributeName, formFieldStates } = params; + + const formFieldState = formFieldStates.find( + ({ attribute }) => attribute.name === attributeName + ); + + assert(formFieldState !== undefined); + + const { attribute } = formFieldState; + + const valueOrValues = (() => { + let { valueOrValues } = formFieldState; + + unFormat_number: { + const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; + + if (!kcNumberUnFormat) { + break unFormat_number; + } + + if (valueOrValues instanceof Array) { + valueOrValues = valueOrValues.map(value => + formatNumber(value, kcNumberUnFormat) + ); + } else { + valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); + } + } + + return valueOrValues; + })(); + + assert(attribute !== undefined); + + server_side_error: { + if (attribute.multivalued) { + const defaultValues = attribute.values?.length ? attribute.values : [""]; + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + if ( + JSON.stringify(defaultValues) !== + JSON.stringify(values.slice(0, defaultValues.length)) + ) { + break server_side_error; + } + } else { + const defaultValue = attribute.value ?? ""; + + assert(typeof valueOrValues === "string"); + + const value = valueOrValues; + + if (defaultValue !== value) { + break server_side_error; + } + } + + let doesErrorExist: boolean; + + try { + doesErrorExist = messagesPerField.existsError(attributeName); + } catch { + break server_side_error; + } + + if (!doesErrorExist) { + break server_side_error; + } + + const errorMessageStr = messagesPerField.get(attributeName); + + return [ + { + advancedMsgArgs: [errorMessageStr], + fieldIndex: undefined, + source: { + type: "server" + } + } + ]; + } + + handle_multi_valued_multi_fields: { + if (!attribute.multivalued) { + break handle_multi_valued_multi_fields; + } + + if (getIsMultivaluedSingleField({ attribute })) { + break handle_multi_valued_multi_fields; + } + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + const errors = values + .map((...[, index]) => { + const specificValueErrors = getErrors({ + attributeName, + formFieldStates: formFieldStates.map(formFieldState => { + if (formFieldState.attribute.name === attributeName) { + assert(formFieldState.valueOrValues instanceof Array); + return { + attribute: { + ...attribute, + annotations: { + ...attribute.annotations, + inputType: undefined + }, + multivalued: false + }, + valueOrValues: formFieldState.valueOrValues[index] + }; + } + + return formFieldState; + }) + }); + + return specificValueErrors + .filter(error => { + if ( + error.source.type === "other" && + error.source.rule === "requiredField" + ) { + return false; + } + + return true; + }) + .map( + (error): FormFieldError => ({ + ...error, + fieldIndex: index + }) + ); + }) + .reduce((acc, errors) => [...acc, ...errors], []); + + required_field: { + if (!attribute.required) { + break required_field; + } + + if (values.every(value => value !== "")) { + break required_field; + } + + errors.push({ + advancedMsgArgs: [ + "error-user-attribute-required" satisfies MessageKey_defaultSet + ] as const, + fieldIndex: undefined, + source: { + type: "other", + rule: "requiredField" + } + }); + } + + return errors; + } + + handle_multi_valued_single_field: { + if (!attribute.multivalued) { + break handle_multi_valued_single_field; + } + + if (!getIsMultivaluedSingleField({ attribute })) { + break handle_multi_valued_single_field; + } + + const validatorName = "multivalued"; + + const validator = attribute.validators[validatorName]; + + if (validator === undefined) { + return []; + } + + const { min: minStr } = validator; + + const min = minStr ? parseInt(`${minStr}`) : attribute.required ? 1 : 0; + + assert(!isNaN(min)); + + const { max: maxStr } = validator; + + const max = !maxStr ? Infinity : parseInt(`${maxStr}`); + + assert(!isNaN(max)); + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + if (min <= values.length && values.length <= max) { + return []; + } + + return [ + { + advancedMsgArgs: [ + "error-invalid-multivalued-size" satisfies MessageKey_defaultSet, + `${min}`, + `${max}` + ] as const, + fieldIndex: undefined, + source: { + type: "validator", + name: validatorName + } + } + ]; + } + + assert(typeof valueOrValues === "string"); + + const value = valueOrValues; + + const errors: FormFieldError[] = []; + + check_password_policies: { + if (attributeName !== "password") { + break check_password_policies; + } + + if (passwordPolicies === undefined) { + break check_password_policies; + } + + check_password_policy_x: { + const policyName = "length"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minLength = policy; + + if (value.length >= minLength) { + break check_password_policy_x; + } + + errors.push({ + advancedMsgArgs: [ + "invalidPasswordMinLengthMessage" satisfies MessageKey_defaultSet, + `${minLength}` + ] as const, + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "digits"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minNumberOfDigits = policy; + + if ( + value.split("").filter(char => !isNaN(parseInt(char))).length >= + minNumberOfDigits + ) { + break check_password_policy_x; + } + + errors.push({ + advancedMsgArgs: [ + "invalidPasswordMinDigitsMessage" satisfies MessageKey_defaultSet, + `${minNumberOfDigits}` + ] as const, + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "lowerCase"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minNumberOfLowerCaseChar = policy; + + if ( + value + .split("") + .filter( + char => + char === char.toLowerCase() && char !== char.toUpperCase() + ).length >= minNumberOfLowerCaseChar + ) { + break check_password_policy_x; + } + + errors.push({ + advancedMsgArgs: [ + "invalidPasswordMinLowerCaseCharsMessage" satisfies MessageKey_defaultSet, + `${minNumberOfLowerCaseChar}` + ] as const, + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "upperCase"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minNumberOfUpperCaseChar = policy; + + if ( + value + .split("") + .filter( + char => + char === char.toUpperCase() && char !== char.toLowerCase() + ).length >= minNumberOfUpperCaseChar + ) { + break check_password_policy_x; + } + + errors.push({ + advancedMsgArgs: [ + "invalidPasswordMinUpperCaseCharsMessage" satisfies MessageKey_defaultSet, + `${minNumberOfUpperCaseChar}` + ] as const, + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "specialChars"; + + const policy = passwordPolicies[policyName]; + + if (!policy) { + break check_password_policy_x; + } + + const minNumberOfSpecialChar = policy; + + if ( + value.split("").filter(char => !char.match(/[a-zA-Z0-9]/)).length >= + minNumberOfSpecialChar + ) { + break check_password_policy_x; + } + + errors.push({ + advancedMsgArgs: [ + "invalidPasswordMinSpecialCharsMessage" satisfies MessageKey_defaultSet, + `${minNumberOfSpecialChar}` + ] as const, + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "notUsername"; + + const notUsername = passwordPolicies[policyName]; + + if (!notUsername) { + break check_password_policy_x; + } + + const usernameFormFieldState = formFieldStates.find( + formFieldState => formFieldState.attribute.name === "username" + ); + + if (!usernameFormFieldState) { + break check_password_policy_x; + } + + const usernameValue = (() => { + let { valueOrValues } = usernameFormFieldState; + + assert(typeof valueOrValues === "string"); + + unFormat_number: { + const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; + + if (!kcNumberUnFormat) { + break unFormat_number; + } + + valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); + } + + return valueOrValues; + })(); + + if (usernameValue === "") { + break check_password_policy_x; + } + + if (value !== usernameValue) { + break check_password_policy_x; + } + + errors.push({ + advancedMsgArgs: [ + "invalidPasswordNotUsernameMessage" satisfies MessageKey_defaultSet + ] as const, + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + + check_password_policy_x: { + const policyName = "notEmail"; + + const notEmail = passwordPolicies[policyName]; + + if (!notEmail) { + break check_password_policy_x; + } + + const emailFormFieldState = formFieldStates.find( + formFieldState => formFieldState.attribute.name === "email" + ); + + if (!emailFormFieldState) { + break check_password_policy_x; + } + + assert(typeof emailFormFieldState.valueOrValues === "string"); + + { + const emailValue = emailFormFieldState.valueOrValues; + + if (emailValue === "") { + break check_password_policy_x; + } + + if (value !== emailValue) { + break check_password_policy_x; + } + } + + errors.push({ + advancedMsgArgs: [ + "invalidPasswordNotEmailMessage" satisfies MessageKey_defaultSet + ] as const, + fieldIndex: undefined, + source: { + type: "passwordPolicy", + name: policyName + } + }); + } + } + + password_confirm_matches_password: { + if (attributeName !== "password-confirm") { + break password_confirm_matches_password; + } + + const passwordFormFieldState = formFieldStates.find( + formFieldState => formFieldState.attribute.name === "password" + ); + + assert(passwordFormFieldState !== undefined); + + assert(typeof passwordFormFieldState.valueOrValues === "string"); + + { + const passwordValue = passwordFormFieldState.valueOrValues; + + if (value === passwordValue) { + break password_confirm_matches_password; + } + } + + errors.push({ + advancedMsgArgs: [ + "invalidPasswordConfirmMessage" satisfies MessageKey_defaultSet + ] as const, + fieldIndex: undefined, + source: { + type: "other", + rule: "passwordConfirmMatchesPassword" + } + }); + } + + const { validators } = attribute; + + required_field: { + if (!attribute.required) { + break required_field; + } + + if (value !== "") { + break required_field; + } + + errors.push({ + advancedMsgArgs: [ + "error-user-attribute-required" satisfies MessageKey_defaultSet + ] as const, + fieldIndex: undefined, + source: { + type: "other", + rule: "requiredField" + } + }); + } + + validator_x: { + const validatorName = "length"; + + const validator = validators[validatorName]; + + if (!validator) { + break validator_x; + } + + const { + "ignore.empty.value": ignoreEmptyValue = false, + max, + min + } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + const source: FormFieldError.Source = { + type: "validator", + name: validatorName + }; + + if (max && value.length > parseInt(`${max}`)) { + errors.push({ + advancedMsgArgs: [ + "error-invalid-length-too-long" satisfies MessageKey_defaultSet, + `${max}` + ] as const, + fieldIndex: undefined, + source + }); + } + + if (min && value.length < parseInt(`${min}`)) { + errors.push({ + advancedMsgArgs: [ + "error-invalid-length-too-short" satisfies MessageKey_defaultSet, + `${min}` + ] as const, + fieldIndex: undefined, + source + }); + } + } + + validator_x: { + const validatorName = "pattern"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + const { + "ignore.empty.value": ignoreEmptyValue = false, + pattern, + "error-message": errorMessageKey + } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + if (new RegExp(pattern).test(value)) { + break validator_x; + } + + const msgArgs = [ + errorMessageKey ?? ("shouldMatchPattern" satisfies MessageKey_defaultSet), + pattern + ] as const; + + errors.push({ + advancedMsgArgs: msgArgs, + fieldIndex: undefined, + source: { + type: "validator", + name: validatorName + } + }); + } + + validator_x: { + { + const lastError = errors[errors.length - 1]; + if ( + lastError !== undefined && + lastError.source.type === "validator" && + lastError.source.name === "pattern" + ) { + break validator_x; + } + } + + const validatorName = "email"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + const { "ignore.empty.value": ignoreEmptyValue = false } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + if (emailRegexp.test(value)) { + break validator_x; + } + + errors.push({ + advancedMsgArgs: [ + "invalidEmailMessage" satisfies MessageKey_defaultSet + ] as const, + fieldIndex: undefined, + source: { + type: "validator", + name: validatorName + } + }); + } + + validator_x: { + const validatorName = "integer"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + const { + "ignore.empty.value": ignoreEmptyValue = false, + max, + min + } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + const intValue = parseInt(value); + + const source: FormFieldError.Source = { + type: "validator", + name: validatorName + }; + + if (isNaN(intValue)) { + const msgArgs = ["mustBeAnInteger"] as const; + + errors.push({ + advancedMsgArgs: msgArgs, + fieldIndex: undefined, + source + }); + + break validator_x; + } + + if (max && intValue > parseInt(`${max}`)) { + errors.push({ + advancedMsgArgs: [ + "error-number-out-of-range-too-big" satisfies MessageKey_defaultSet, + `${max}` + ] as const, + fieldIndex: undefined, + source + }); + + break validator_x; + } + + if (min && intValue < parseInt(`${min}`)) { + errors.push({ + advancedMsgArgs: [ + "error-number-out-of-range-too-small" satisfies MessageKey_defaultSet, + `${min}` + ] as const, + fieldIndex: undefined, + source + }); + break validator_x; + } + } + + validator_x: { + const validatorName = "options"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + if (value === "") { + break validator_x; + } + + if (validator.options.indexOf(value) >= 0) { + break validator_x; + } + + errors.push({ + advancedMsgArgs: [ + "notAValidOption" satisfies MessageKey_defaultSet + ] as const, + fieldIndex: undefined, + source: { + type: "validator", + name: validatorName + } + }); + } + + //TODO: Implement missing validators. See Validators type definition. + + return errors; + } + + return { getErrors }; +} + +function getIsMultivaluedSingleField(params: { attribute: Attribute }) { + const { attribute } = params; + + return attribute.annotations.inputType?.startsWith("multiselect") ?? false; +} + +export function getButtonToDisplayForMultivaluedAttributeField(params: { + attribute: Attribute; + values: string[]; + fieldIndex: number; +}) { + const { attribute, values, fieldIndex } = params; + + const hasRemove = (() => { + if (values.length === 1) { + return false; + } + + const minCount = (() => { + const { multivalued } = attribute.validators; + + if (multivalued === undefined) { + return undefined; + } + + const minStr = multivalued.min; + + if (minStr === undefined) { + return undefined; + } + + return parseInt(`${minStr}`); + })(); + + if (minCount === undefined) { + return true; + } + + if (values.length === minCount) { + return false; + } + + return true; + })(); + + const hasAdd = (() => { + if (fieldIndex + 1 !== values.length) { + return false; + } + + const maxCount = (() => { + const { multivalued } = attribute.validators; + + if (multivalued === undefined) { + return undefined; + } + + const maxStr = multivalued.max; + + if (maxStr === undefined) { + return undefined; + } + + return parseInt(`${maxStr}`); + })(); + + if (maxCount === undefined) { + return true; + } + + return values.length !== maxCount; + })(); + + return { hasRemove, hasAdd }; +} diff --git a/src/login/lib/getUserProfileApi/index.ts b/src/login/lib/getUserProfileApi/index.ts new file mode 100644 index 00000000..2a716ac3 --- /dev/null +++ b/src/login/lib/getUserProfileApi/index.ts @@ -0,0 +1 @@ +export * from "./getUserProfileApi"; diff --git a/src/login/lib/getUserProfileApi/kcNumberUnFormat.ts b/src/login/lib/getUserProfileApi/kcNumberUnFormat.ts new file mode 100644 index 00000000..e852fcde --- /dev/null +++ b/src/login/lib/getUserProfileApi/kcNumberUnFormat.ts @@ -0,0 +1,109 @@ +import { assert } from "keycloakify/tools/assert"; +let cleanup: (() => void) | undefined; +const handledElements = new WeakSet(); +const KC_NUMBER_UNFORMAT = "kcNumberUnFormat"; +const SELECTOR = `input[data-${KC_NUMBER_UNFORMAT}]`; + +export function unFormatNumberOnSubmit() { + cleanup?.(); + + const handleElement = (element: HTMLInputElement) => { + if (handledElements.has(element)) { + return; + } + + const form = element.closest("form"); + + if (form === null) { + return; + } + + form.addEventListener("submit", () => { + const rawFormat = element.getAttribute(`data-${KC_NUMBER_UNFORMAT}`); + if (rawFormat) { + element.value = formatNumber(element.value, rawFormat); + } + }); + + handledElements.add(element); + }; + + document.querySelectorAll(SELECTOR).forEach(element => { + assert(element instanceof HTMLInputElement); + handleElement(element); + }); + + const observer = new MutationObserver(mutationsList => { + for (const mutation of mutationsList) { + if (mutation.type === "childList" && mutation.addedNodes.length > 0) { + mutation.addedNodes.forEach(node => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = (node as HTMLElement).querySelector(SELECTOR); + if (element !== null) { + assert(element instanceof HTMLInputElement); + handleElement(element); + } + } + }); + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + cleanup = () => observer.disconnect(); +} + +// NOTE: Keycloak code +const formatNumber = (input: string, format: string) => { + if (!input) { + return ""; + } + + // array holding the patterns for the number of expected digits in each part + const digitPattern = format.match(/{\d+}/g); + + if (!digitPattern) { + return ""; + } + + // calculate the maximum size of the given pattern based on the sum of the expected digits + const maxSize = digitPattern.reduce( + (total, p) => total + parseInt(p.replace("{", "").replace("}", "")), + 0 + ); + + // keep only digits + let rawValue = input.replace(/\D+/g, ""); + + // make sure the value is a number + //@ts-expect-error + if (parseInt(rawValue) != rawValue) { + return ""; + } + + // make sure the number of digits does not exceed the maximum size + if (rawValue.length > maxSize) { + rawValue = rawValue.substring(0, maxSize); + } + + // build the regex based based on the expected digits in each part + const formatter = digitPattern.reduce((result, p) => result + `(\\d${p})`, "^"); + + // if the current digits match the pattern we have each group of digits in an array + let digits = new RegExp(formatter).exec(rawValue); + + // no match, return the raw value without any format + if (!digits) { + return input; + } + + let result = format; + + // finally format the current digits accordingly to the given format + for (let i = 0; i < digitPattern.length; i++) { + result = result.replace(digitPattern[i], digits[i + 1]); + } + + return result; +}; diff --git a/src/login/lib/useUserProfileForm copy.tsx b/src/login/lib/useUserProfileForm copy.tsx deleted file mode 100644 index 31ad939e..00000000 --- a/src/login/lib/useUserProfileForm copy.tsx +++ /dev/null @@ -1,1403 +0,0 @@ -import "keycloakify/tools/Array.prototype.every"; -import { useMemo, useReducer, useEffect, Fragment, type Dispatch } from "react"; -import { assert, type Equals } from "tsafe/assert"; -import { id } from "tsafe/id"; -import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions"; -import { kcSanitize } from "keycloakify/lib/kcSanitize"; -import { useConstCallback } from "keycloakify/tools/useConstCallback"; -import { emailRegexp } from "keycloakify/tools/emailRegExp"; -import { formatNumber } from "keycloakify/tools/formatNumber"; -import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; -import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext"; -import type { KcContext } from "../KcContext"; -import type { MessageKey_defaultSet } from "keycloakify/login/i18n"; -import type { I18n } from "../i18n"; - -export type FormFieldError = { - errorMessage: JSX.Element; - errorMessageStr: string; - source: FormFieldError.Source; - fieldIndex: number | undefined; -}; - -export namespace FormFieldError { - export type Source = Source.Validator | Source.PasswordPolicy | Source.Server | Source.Other; - - export namespace Source { - export type Validator = { - type: "validator"; - name: keyof Validators; - }; - export type PasswordPolicy = { - type: "passwordPolicy"; - name: keyof PasswordPolicies; - }; - export type Server = { - type: "server"; - }; - - export type Other = { - type: "other"; - rule: "passwordConfirmMatchesPassword" | "requiredField"; - }; - } -} - -export type FormFieldState = { - attribute: Attribute; - displayableErrors: FormFieldError[]; - valueOrValues: string | string[]; -}; - -export type FormState = { - isFormSubmittable: boolean; - formFieldStates: FormFieldState[]; -}; - -export type FormAction = - | { - action: "update"; - name: string; - valueOrValues: string | string[]; - /** Default false */ - displayErrorsImmediately?: boolean; - } - | { - action: "focus lost"; - name: string; - fieldIndex: number | undefined; - }; - -export type KcContextLike = KcContextLike_useGetErrors & { - profile: { - attributesByName: Record; - html5DataAnnotations?: Record; - }; - passwordRequired?: boolean; - realm: { registrationEmailAsUsername: boolean }; - url: { - resourcesPath: string; - }; -}; - -assert, { pageId: "register.ftl" }> extends KcContextLike ? true : false>(); - -export type UseUserProfileFormParams = { - kcContext: KcContextLike; - i18n: I18n; - doMakeUserConfirmPassword: boolean; -}; - -export type ReturnTypeOfUseUserProfileForm = { - formState: FormState; - dispatchFormAction: Dispatch; -}; - -namespace internal { - export type FormFieldState = { - attribute: Attribute; - errors: FormFieldError[]; - hasLostFocusAtLeastOnce: boolean | boolean[]; - valueOrValues: string | string[]; - }; - - export type State = { - formFieldStates: FormFieldState[]; - }; -} - -export function useUserProfileForm(params: UseUserProfileFormParams): ReturnTypeOfUseUserProfileForm { - const { kcContext, i18n, doMakeUserConfirmPassword } = params; - - const { insertScriptTags } = useInsertScriptTags({ - componentOrHookName: "useUserProfileForm", - scriptTags: Object.keys(kcContext.profile?.html5DataAnnotations ?? {}) - .filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it. - .map(key => ({ - type: "module", - src: `${kcContext.url.resourcesPath}/js/${key}.js` - })) - }); - - useEffect(() => { - insertScriptTags(); - }, []); - - const { getErrors } = useGetErrors({ - kcContext, - i18n - }); - - const initialState = useMemo((): internal.State => { - // NOTE: We don't use te kcContext.profile.attributes directly because - // they don't includes the password and password confirm fields and we want to add them. - // We also want to apply some retro-compatibility and consistency patches. - const attributes: Attribute[] = (() => { - mock_user_profile_attributes_for_older_keycloak_versions: { - if ( - "profile" in kcContext && - "attributesByName" in kcContext.profile && - Object.keys(kcContext.profile.attributesByName).length !== 0 - ) { - break mock_user_profile_attributes_for_older_keycloak_versions; - } - - if ("register" in kcContext && kcContext.register instanceof Object && "formData" in kcContext.register) { - //NOTE: Handle legacy register.ftl page - return (["firstName", "lastName", "email", "username"] as const) - .filter(name => (name !== "username" ? true : !kcContext.realm.registrationEmailAsUsername)) - .map(name => - id({ - name: name, - displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), - required: true, - value: (kcContext.register as any).formData[name] ?? "", - html5DataAnnotations: {}, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: (() => { - switch (name) { - case "email": - return "email"; - case "username": - return "username"; - default: - return undefined; - } - })() - }) - ); - } - - if ("user" in kcContext && kcContext.user instanceof Object) { - //NOTE: Handle legacy login-update-profile.ftl - return (["username", "email", "firstName", "lastName"] as const) - .filter(name => (name !== "username" ? true : (kcContext.user as any).editUsernameAllowed)) - .map(name => - id({ - name: name, - displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), - required: true, - value: (kcContext as any).user[name] ?? "", - html5DataAnnotations: {}, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: (() => { - switch (name) { - case "email": - return "email"; - case "username": - return "username"; - default: - return undefined; - } - })() - }) - ); - } - - if ("email" in kcContext && kcContext.email instanceof Object) { - //NOTE: Handle legacy update-email.ftl - return [ - id({ - name: "email", - displayName: id<`\${${MessageKey_defaultSet}}`>(`\${email}`), - required: true, - value: (kcContext.email as any).value ?? "", - html5DataAnnotations: {}, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: "email" - }) - ]; - } - - assert(false, "Unable to mock user profile from the current kcContext"); - } - - return Object.values(kcContext.profile.attributesByName).map(structuredCloneButFunctions); - })(); - - // Retro-compatibility and consistency patches - attributes.forEach(attribute => { - patch_legacy_group: { - if (typeof attribute.group !== "string") { - break patch_legacy_group; - } - - const { group, groupDisplayHeader, groupDisplayDescription, groupAnnotations } = attribute as Attribute & { - group: string; - groupDisplayHeader?: string; - groupDisplayDescription?: string; - groupAnnotations: Record; - }; - - delete attribute.group; - // @ts-expect-error - delete attribute.groupDisplayHeader; - // @ts-expect-error - delete attribute.groupDisplayDescription; - // @ts-expect-error - delete attribute.groupAnnotations; - - if (group === "") { - break patch_legacy_group; - } - - attribute.group = { - name: group, - displayHeader: groupDisplayHeader, - displayDescription: groupDisplayDescription, - annotations: groupAnnotations, - html5DataAnnotations: {} - }; - } - - // Attributes with options rendered by default as select inputs - if (attribute.validators.options !== undefined && attribute.annotations.inputType === undefined) { - attribute.annotations.inputType = "select"; - } - - // Consistency patch on values/value property - { - if (getIsMultivaluedSingleField({ attribute })) { - attribute.multivalued = true; - } - - if (attribute.multivalued) { - attribute.values ??= attribute.value !== undefined ? [attribute.value] : []; - delete attribute.value; - } else { - attribute.value ??= attribute.values?.[0]; - delete attribute.values; - } - } - }); - - add_password_and_password_confirm: { - if (!kcContext.passwordRequired) { - break add_password_and_password_confirm; - } - - attributes.forEach((attribute, i) => { - if (attribute.name !== (kcContext.realm.registrationEmailAsUsername ? "email" : "username")) { - // NOTE: We want to add password and password-confirm after the field that identifies the user. - // It's either email or username. - return; - } - - attributes.splice( - i + 1, - 0, - { - name: "password", - displayName: id<`\${${MessageKey_defaultSet}}`>("${password}"), - required: true, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: "new-password", - html5DataAnnotations: {} - }, - { - name: "password-confirm", - displayName: id<`\${${MessageKey_defaultSet}}`>("${passwordConfirm}"), - required: true, - readOnly: false, - validators: {}, - annotations: {}, - html5DataAnnotations: {}, - autocomplete: "new-password" - } - ); - }); - } - - const initialFormFieldState: { - attribute: Attribute; - valueOrValues: string | string[]; - }[] = []; - - for (const attribute of attributes) { - handle_multi_valued_attribute: { - if (!attribute.multivalued) { - break handle_multi_valued_attribute; - } - - const values = attribute.values?.length ? attribute.values : [""]; - - apply_validator_min_range: { - if (getIsMultivaluedSingleField({ attribute })) { - break apply_validator_min_range; - } - - const validator = attribute.validators.multivalued; - - if (validator === undefined) { - break apply_validator_min_range; - } - - const { min: minStr } = validator; - - if (!minStr) { - break apply_validator_min_range; - } - - const min = parseInt(`${minStr}`); - - for (let index = values.length; index < min; index++) { - values.push(""); - } - } - - initialFormFieldState.push({ - attribute, - valueOrValues: values - }); - - continue; - } - - initialFormFieldState.push({ - attribute, - valueOrValues: attribute.value ?? "" - }); - } - - const initialState: internal.State = { - formFieldStates: initialFormFieldState.map(({ attribute, valueOrValues }) => ({ - attribute, - errors: getErrors({ - attributeName: attribute.name, - formFieldStates: initialFormFieldState - }), - hasLostFocusAtLeastOnce: - valueOrValues instanceof Array && !getIsMultivaluedSingleField({ attribute }) ? valueOrValues.map(() => false) : false, - valueOrValues: valueOrValues - })) - }; - - return initialState; - }, []); - - const [state, dispatchFormAction] = useReducer(function reducer(state: internal.State, formAction: FormAction): internal.State { - const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === formAction.name); - - assert(formFieldState !== undefined); - - (() => { - switch (formAction.action) { - case "update": - formFieldState.valueOrValues = formAction.valueOrValues; - - apply_formatters: { - const { attribute } = formFieldState; - - const { kcNumberFormat } = attribute.html5DataAnnotations ?? {}; - - if (!kcNumberFormat) { - break apply_formatters; - } - - if (formFieldState.valueOrValues instanceof Array) { - formFieldState.valueOrValues = formFieldState.valueOrValues.map(value => formatNumber(value, kcNumberFormat)); - } else { - formFieldState.valueOrValues = formatNumber(formFieldState.valueOrValues, kcNumberFormat); - } - } - - formFieldState.errors = getErrors({ - attributeName: formAction.name, - formFieldStates: state.formFieldStates - }); - - simulate_focus_lost: { - const { displayErrorsImmediately = false } = formAction; - - if (!displayErrorsImmediately) { - break simulate_focus_lost; - } - - for (const fieldIndex of formAction.valueOrValues instanceof Array - ? formAction.valueOrValues.map((...[, index]) => index) - : [undefined]) { - state = reducer(state, { - action: "focus lost", - name: formAction.name, - fieldIndex - }); - } - } - - update_password_confirm: { - if (doMakeUserConfirmPassword) { - break update_password_confirm; - } - - if (formAction.name !== "password") { - break update_password_confirm; - } - - state = reducer(state, { - action: "update", - name: "password-confirm", - valueOrValues: formAction.valueOrValues, - displayErrorsImmediately: formAction.displayErrorsImmediately - }); - } - - trigger_password_confirm_validation_on_password_change: { - if (!doMakeUserConfirmPassword) { - break trigger_password_confirm_validation_on_password_change; - } - - if (formAction.name !== "password") { - break trigger_password_confirm_validation_on_password_change; - } - - state = reducer(state, { - action: "update", - name: "password-confirm", - valueOrValues: (() => { - const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === "password-confirm"); - - assert(formFieldState !== undefined); - - return formFieldState.valueOrValues; - })(), - displayErrorsImmediately: formAction.displayErrorsImmediately - }); - } - - return; - case "focus lost": - if (formFieldState.hasLostFocusAtLeastOnce instanceof Array) { - const { fieldIndex } = formAction; - assert(fieldIndex !== undefined); - formFieldState.hasLostFocusAtLeastOnce[fieldIndex] = true; - return; - } - - formFieldState.hasLostFocusAtLeastOnce = true; - return; - } - assert>(false); - })(); - - return { ...state }; - }, initialState); - - const formState: FormState = useMemo( - () => ({ - formFieldStates: state.formFieldStates.map( - ({ errors, hasLostFocusAtLeastOnce: hasLostFocusAtLeastOnceOrArr, attribute, ...valueOrValuesWrap }) => ({ - displayableErrors: errors.filter(error => { - const hasLostFocusAtLeastOnce = - typeof hasLostFocusAtLeastOnceOrArr === "boolean" - ? hasLostFocusAtLeastOnceOrArr - : error.fieldIndex !== undefined - ? hasLostFocusAtLeastOnceOrArr[error.fieldIndex] - : hasLostFocusAtLeastOnceOrArr[hasLostFocusAtLeastOnceOrArr.length - 1]; - - switch (error.source.type) { - case "server": - return true; - case "other": - switch (error.source.rule) { - case "requiredField": - return hasLostFocusAtLeastOnce; - case "passwordConfirmMatchesPassword": - return hasLostFocusAtLeastOnce; - } - assert>(false); - case "passwordPolicy": - switch (error.source.name) { - case "length": - return hasLostFocusAtLeastOnce; - case "digits": - return hasLostFocusAtLeastOnce; - case "lowerCase": - return hasLostFocusAtLeastOnce; - case "upperCase": - return hasLostFocusAtLeastOnce; - case "specialChars": - return hasLostFocusAtLeastOnce; - case "notUsername": - return true; - case "notEmail": - return true; - } - assert>(false); - case "validator": - switch (error.source.name) { - case "length": - return hasLostFocusAtLeastOnce; - case "pattern": - return hasLostFocusAtLeastOnce; - case "email": - return hasLostFocusAtLeastOnce; - case "integer": - return hasLostFocusAtLeastOnce; - case "multivalued": - return hasLostFocusAtLeastOnce; - case "options": - return hasLostFocusAtLeastOnce; - } - assert>(false); - } - }), - attribute, - ...valueOrValuesWrap - }) - ), - isFormSubmittable: state.formFieldStates.every(({ errors }) => errors.length === 0) - }), - [state] - ); - - return { - formState, - dispatchFormAction - }; -} - -type KcContextLike_useGetErrors = { - messagesPerField: Pick; - passwordPolicies?: PasswordPolicies; -}; - -assert(); - -function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18n }) { - const { kcContext, i18n } = params; - - const { messagesPerField, passwordPolicies } = kcContext; - - const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n; - - const getErrors = useConstCallback( - (params: { - attributeName: string; - formFieldStates: { - attribute: Attribute; - valueOrValues: string | string[]; - }[]; - }): FormFieldError[] => { - const { attributeName, formFieldStates } = params; - - const formFieldState = formFieldStates.find(({ attribute }) => attribute.name === attributeName); - - assert(formFieldState !== undefined); - - const { attribute } = formFieldState; - - const valueOrValues = (() => { - let { valueOrValues } = formFieldState; - - unFormat_number: { - const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; - - if (!kcNumberUnFormat) { - break unFormat_number; - } - - if (valueOrValues instanceof Array) { - valueOrValues = valueOrValues.map(value => formatNumber(value, kcNumberUnFormat)); - } else { - valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); - } - } - - return valueOrValues; - })(); - - assert(attribute !== undefined); - - server_side_error: { - if (attribute.multivalued) { - const defaultValues = attribute.values?.length ? attribute.values : [""]; - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - if (JSON.stringify(defaultValues) !== JSON.stringify(values.slice(0, defaultValues.length))) { - break server_side_error; - } - } else { - const defaultValue = attribute.value ?? ""; - - assert(typeof valueOrValues === "string"); - - const value = valueOrValues; - - if (defaultValue !== value) { - break server_side_error; - } - } - - let doesErrorExist: boolean; - - try { - doesErrorExist = messagesPerField.existsError(attributeName); - } catch { - break server_side_error; - } - - if (!doesErrorExist) { - break server_side_error; - } - - const errorMessageStr = messagesPerField.get(attributeName); - - return [ - { - errorMessageStr, - errorMessage: ( - - ), - fieldIndex: undefined, - source: { - type: "server" - } - } - ]; - } - - handle_multi_valued_multi_fields: { - if (!attribute.multivalued) { - break handle_multi_valued_multi_fields; - } - - if (getIsMultivaluedSingleField({ attribute })) { - break handle_multi_valued_multi_fields; - } - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - const errors = values - .map((...[, index]) => { - const specificValueErrors = getErrors({ - attributeName, - formFieldStates: formFieldStates.map(formFieldState => { - if (formFieldState.attribute.name === attributeName) { - assert(formFieldState.valueOrValues instanceof Array); - return { - attribute: { - ...attribute, - annotations: { - ...attribute.annotations, - inputType: undefined - }, - multivalued: false - }, - valueOrValues: formFieldState.valueOrValues[index] - }; - } - - return formFieldState; - }) - }); - - return specificValueErrors - .filter(error => { - if (error.source.type === "other" && error.source.rule === "requiredField") { - return false; - } - - return true; - }) - .map( - (error): FormFieldError => ({ - ...error, - fieldIndex: index - }) - ); - }) - .reduce((acc, errors) => [...acc, ...errors], []); - - required_field: { - if (!attribute.required) { - break required_field; - } - - if (values.every(value => value !== "")) { - break required_field; - } - - const msgArgs = ["error-user-attribute-required"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "other", - rule: "requiredField" - } - }); - } - - return errors; - } - - handle_multi_valued_single_field: { - if (!attribute.multivalued) { - break handle_multi_valued_single_field; - } - - if (!getIsMultivaluedSingleField({ attribute })) { - break handle_multi_valued_single_field; - } - - const validatorName = "multivalued"; - - const validator = attribute.validators[validatorName]; - - if (validator === undefined) { - return []; - } - - const { min: minStr } = validator; - - const min = minStr ? parseInt(`${minStr}`) : attribute.required ? 1 : 0; - - assert(!isNaN(min)); - - const { max: maxStr } = validator; - - const max = !maxStr ? Infinity : parseInt(`${maxStr}`); - - assert(!isNaN(max)); - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - if (min <= values.length && values.length <= max) { - return []; - } - - const msgArgs = ["error-invalid-multivalued-size", `${min}`, `${max}`] as const; - - return [ - { - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "validator", - name: validatorName - } - } - ]; - } - - assert(typeof valueOrValues === "string"); - - const value = valueOrValues; - - const errors: FormFieldError[] = []; - - check_password_policies: { - if (attributeName !== "password") { - break check_password_policies; - } - - if (passwordPolicies === undefined) { - break check_password_policies; - } - - check_password_policy_x: { - const policyName = "length"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minLength = policy; - - if (value.length >= minLength) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinLengthMessage", `${minLength}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "digits"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minNumberOfDigits = policy; - - if (value.split("").filter(char => !isNaN(parseInt(char))).length >= minNumberOfDigits) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinDigitsMessage", `${minNumberOfDigits}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "lowerCase"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minNumberOfLowerCaseChar = policy; - - if ( - value.split("").filter(char => char === char.toLowerCase() && char !== char.toUpperCase()).length >= minNumberOfLowerCaseChar - ) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinLowerCaseCharsMessage", `${minNumberOfLowerCaseChar}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "upperCase"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minNumberOfUpperCaseChar = policy; - - if ( - value.split("").filter(char => char === char.toUpperCase() && char !== char.toLowerCase()).length >= minNumberOfUpperCaseChar - ) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinUpperCaseCharsMessage", `${minNumberOfUpperCaseChar}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "specialChars"; - - const policy = passwordPolicies[policyName]; - - if (!policy) { - break check_password_policy_x; - } - - const minNumberOfSpecialChar = policy; - - if (value.split("").filter(char => !char.match(/[a-zA-Z0-9]/)).length >= minNumberOfSpecialChar) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordMinSpecialCharsMessage", `${minNumberOfSpecialChar}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "notUsername"; - - const notUsername = passwordPolicies[policyName]; - - if (!notUsername) { - break check_password_policy_x; - } - - const usernameFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "username"); - - if (!usernameFormFieldState) { - break check_password_policy_x; - } - - const usernameValue = (() => { - let { valueOrValues } = usernameFormFieldState; - - assert(typeof valueOrValues === "string"); - - unFormat_number: { - const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; - - if (!kcNumberUnFormat) { - break unFormat_number; - } - - valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); - } - - return valueOrValues; - })(); - - if (usernameValue === "") { - break check_password_policy_x; - } - - if (value !== usernameValue) { - break check_password_policy_x; - } - - const msgArgs = ["invalidPasswordNotUsernameMessage"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - - check_password_policy_x: { - const policyName = "notEmail"; - - const notEmail = passwordPolicies[policyName]; - - if (!notEmail) { - break check_password_policy_x; - } - - const emailFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "email"); - - if (!emailFormFieldState) { - break check_password_policy_x; - } - - assert(typeof emailFormFieldState.valueOrValues === "string"); - - { - const emailValue = emailFormFieldState.valueOrValues; - - if (emailValue === "") { - break check_password_policy_x; - } - - if (value !== emailValue) { - break check_password_policy_x; - } - } - - const msgArgs = ["invalidPasswordNotEmailMessage"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "passwordPolicy", - name: policyName - } - }); - } - } - - password_confirm_matches_password: { - if (attributeName !== "password-confirm") { - break password_confirm_matches_password; - } - - const passwordFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "password"); - - assert(passwordFormFieldState !== undefined); - - assert(typeof passwordFormFieldState.valueOrValues === "string"); - - { - const passwordValue = passwordFormFieldState.valueOrValues; - - if (value === passwordValue) { - break password_confirm_matches_password; - } - } - - const msgArgs = ["invalidPasswordConfirmMessage"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "other", - rule: "passwordConfirmMatchesPassword" - } - }); - } - - const { validators } = attribute; - - required_field: { - if (!attribute.required) { - break required_field; - } - - if (value !== "") { - break required_field; - } - - const msgArgs = ["error-user-attribute-required"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "other", - rule: "requiredField" - } - }); - } - - validator_x: { - const validatorName = "length"; - - const validator = validators[validatorName]; - - if (!validator) { - break validator_x; - } - - const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; - - if (ignoreEmptyValue && value === "") { - break validator_x; - } - - const source: FormFieldError.Source = { - type: "validator", - name: validatorName - }; - - if (max && value.length > parseInt(`${max}`)) { - const msgArgs = ["error-invalid-length-too-long", `${max}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - } - - if (min && value.length < parseInt(`${min}`)) { - const msgArgs = ["error-invalid-length-too-short", `${min}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - } - } - - validator_x: { - const validatorName = "pattern"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break validator_x; - } - - const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator; - - if (ignoreEmptyValue && value === "") { - break validator_x; - } - - if (new RegExp(pattern).test(value)) { - break validator_x; - } - - const msgArgs = [errorMessageKey ?? id("shouldMatchPattern"), pattern] as const; - - errors.push({ - errorMessage: {advancedMsg(...msgArgs)}, - errorMessageStr: advancedMsgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "validator", - name: validatorName - } - }); - } - - validator_x: { - { - const lastError = errors[errors.length - 1]; - if (lastError !== undefined && lastError.source.type === "validator" && lastError.source.name === "pattern") { - break validator_x; - } - } - - const validatorName = "email"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break validator_x; - } - - const { "ignore.empty.value": ignoreEmptyValue = false } = validator; - - if (ignoreEmptyValue && value === "") { - break validator_x; - } - - if (emailRegexp.test(value)) { - break validator_x; - } - - const msgArgs = [id("invalidEmailMessage")] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "validator", - name: validatorName - } - }); - } - - validator_x: { - const validatorName = "integer"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break validator_x; - } - - const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; - - if (ignoreEmptyValue && value === "") { - break validator_x; - } - - const intValue = parseInt(value); - - const source: FormFieldError.Source = { - type: "validator", - name: validatorName - }; - - if (isNaN(intValue)) { - const msgArgs = ["mustBeAnInteger"] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - - break validator_x; - } - - if (max && intValue > parseInt(`${max}`)) { - const msgArgs = ["error-number-out-of-range-too-big", `${max}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - - break validator_x; - } - - if (min && intValue < parseInt(`${min}`)) { - const msgArgs = ["error-number-out-of-range-too-small", `${min}`] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source - }); - - break validator_x; - } - } - - validator_x: { - const validatorName = "options"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break validator_x; - } - - if (value === "") { - break validator_x; - } - - if (validator.options.indexOf(value) >= 0) { - break validator_x; - } - - const msgArgs = [id("notAValidOption")] as const; - - errors.push({ - errorMessage: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "validator", - name: validatorName - } - }); - } - - //TODO: Implement missing validators. See Validators type definition. - - return errors; - } - ); - - return { getErrors }; -} - -function getIsMultivaluedSingleField(params: { attribute: Attribute }) { - const { attribute } = params; - - return attribute.annotations.inputType?.startsWith("multiselect") ?? false; -} - -export function getButtonToDisplayForMultivaluedAttributeField(params: { attribute: Attribute; values: string[]; fieldIndex: number }) { - const { attribute, values, fieldIndex } = params; - - const hasRemove = (() => { - if (values.length === 1) { - return false; - } - - const minCount = (() => { - const { multivalued } = attribute.validators; - - if (multivalued === undefined) { - return undefined; - } - - const minStr = multivalued.min; - - if (minStr === undefined) { - return undefined; - } - - return parseInt(`${minStr}`); - })(); - - if (minCount === undefined) { - return true; - } - - if (values.length === minCount) { - return false; - } - - return true; - })(); - - const hasAdd = (() => { - if (fieldIndex + 1 !== values.length) { - return false; - } - - const maxCount = (() => { - const { multivalued } = attribute.validators; - - if (multivalued === undefined) { - return undefined; - } - - const maxStr = multivalued.max; - - if (maxStr === undefined) { - return undefined; - } - - return parseInt(`${maxStr}`); - })(); - - if (maxCount === undefined) { - return true; - } - - return values.length !== maxCount; - })(); - - return { hasRemove, hasAdd }; -} diff --git a/src/login/lib/useUserProfileForm.tsx b/src/login/lib/useUserProfileForm.tsx index 3d997549..c01eafea 100644 --- a/src/login/lib/useUserProfileForm.tsx +++ b/src/login/lib/useUserProfileForm.tsx @@ -1,8 +1,9 @@ -import * as reactlessApi from "./getUserProfileApi"; +import * as reactlessApi from "./getUserProfileApi/index"; import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext"; import { useEffect, useState, useMemo, Fragment } from "react"; import { assert, type Equals } from "tsafe/assert"; import type { I18n } from "../i18n"; +export { getButtonToDisplayForMultivaluedAttributeField } from "./getUserProfileApi/index"; export type FormFieldError = { errorMessage: JSX.Element; From 06e33196bb12d5a405e8b9bd0927dbac5ae9fd07 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 00:31:27 +0200 Subject: [PATCH 03/82] Refactor: Make ClassKey importable without having react as a dependency --- src/account/TemplateProps.ts | 16 +---- src/account/lib/kcClsx.ts | 18 ++++- src/login/TemplateProps.ts | 127 +--------------------------------- src/login/lib/kcClsx.ts | 129 ++++++++++++++++++++++++++++++++++- 4 files changed, 145 insertions(+), 145 deletions(-) diff --git a/src/account/TemplateProps.ts b/src/account/TemplateProps.ts index 0e5196d2..d8f26cbd 100644 --- a/src/account/TemplateProps.ts +++ b/src/account/TemplateProps.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import type { ClassKey } from "keycloakify/account/lib/kcClsx"; export type TemplateProps = { kcContext: KcContext; @@ -10,17 +11,4 @@ export type TemplateProps = { active: string; }; -export type ClassKey = - | "kcHtmlClass" - | "kcBodyClass" - | "kcButtonClass" - | "kcButtonPrimaryClass" - | "kcButtonLargeClass" - | "kcButtonDefaultClass" - | "kcContentWrapperClass" - | "kcFormClass" - | "kcFormGroupClass" - | "kcInputWrapperClass" - | "kcLabelClass" - | "kcInputClass" - | "kcInputErrorMessageClass"; +export type { ClassKey }; diff --git a/src/account/lib/kcClsx.ts b/src/account/lib/kcClsx.ts index 330e0c4f..33df4515 100644 --- a/src/account/lib/kcClsx.ts +++ b/src/account/lib/kcClsx.ts @@ -1,5 +1,19 @@ import { createGetKcClsx } from "keycloakify/lib/getKcClsx"; -import type { ClassKey } from "keycloakify/account/TemplateProps"; + +export type ClassKey = + | "kcHtmlClass" + | "kcBodyClass" + | "kcButtonClass" + | "kcButtonPrimaryClass" + | "kcButtonLargeClass" + | "kcButtonDefaultClass" + | "kcContentWrapperClass" + | "kcFormClass" + | "kcFormGroupClass" + | "kcInputWrapperClass" + | "kcLabelClass" + | "kcInputClass" + | "kcInputErrorMessageClass"; export const { getKcClsx } = createGetKcClsx({ defaultClasses: { @@ -20,6 +34,4 @@ export const { getKcClsx } = createGetKcClsx({ } }); -export type { ClassKey }; - export type KcClsx = ReturnType["kcClsx"]; diff --git a/src/login/TemplateProps.ts b/src/login/TemplateProps.ts index 0e3b2895..b3b8f7c6 100644 --- a/src/login/TemplateProps.ts +++ b/src/login/TemplateProps.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import type { ClassKey } from "keycloakify/login/lib/kcClsx"; export type TemplateProps = { kcContext: KcContext; @@ -18,128 +19,4 @@ export type TemplateProps = { bodyClassName?: string; }; -export type ClassKey = - | "kcBodyClass" - | "kcHeaderWrapperClass" - | "kcLocaleWrapperClass" - | "kcInfoAreaWrapperClass" - | "kcFormButtonsWrapperClass" - | "kcFormOptionsWrapperClass" - | "kcCheckboxInputClass" - | "kcLocaleDropDownClass" - | "kcLocaleListItemClass" - | "kcContentWrapperClass" - | "kcLogoIdP-facebook" - | "kcAuthenticatorOTPClass" - | "kcLogoIdP-bitbucket" - | "kcAuthenticatorWebAuthnClass" - | "kcWebAuthnDefaultIcon" - | "kcLogoIdP-stackoverflow" - | "kcSelectAuthListItemClass" - | "kcLogoIdP-microsoft" - | "kcLoginOTPListItemHeaderClass" - | "kcLocaleItemClass" - | "kcLoginOTPListItemIconBodyClass" - | "kcInputHelperTextAfterClass" - | "kcFormClass" - | "kcSelectAuthListClass" - | "kcInputClassRadioCheckboxLabelDisabled" - | "kcSelectAuthListItemIconClass" - | "kcRecoveryCodesWarning" - | "kcFormSettingClass" - | "kcWebAuthnBLE" - | "kcInputWrapperClass" - | "kcSelectAuthListItemArrowIconClass" - | "kcFeedbackAreaClass" - | "kcFormPasswordVisibilityButtonClass" - | "kcLogoIdP-google" - | "kcCheckLabelClass" - | "kcSelectAuthListItemFillClass" - | "kcAuthenticatorDefaultClass" - | "kcLogoIdP-gitlab" - | "kcFormAreaClass" - | "kcFormButtonsClass" - | "kcInputClassRadioLabel" - | "kcAuthenticatorWebAuthnPasswordlessClass" - | "kcSelectAuthListItemHeadingClass" - | "kcInfoAreaClass" - | "kcLogoLink" - | "kcContainerClass" - | "kcSelectAuthListItemTitle" - | "kcHtmlClass" - | "kcLoginOTPListItemTitleClass" - | "kcLogoIdP-openshift-v4" - | "kcWebAuthnUnknownIcon" - | "kcFormSocialAccountNameClass" - | "kcLogoIdP-openshift-v3" - | "kcLoginOTPListInputClass" - | "kcWebAuthnUSB" - | "kcInputClassRadio" - | "kcWebAuthnKeyIcon" - | "kcFeedbackInfoIcon" - | "kcCommonLogoIdP" - | "kcRecoveryCodesActions" - | "kcFormGroupHeader" - | "kcFormSocialAccountSectionClass" - | "kcLogoIdP-instagram" - | "kcAlertClass" - | "kcHeaderClass" - | "kcLabelWrapperClass" - | "kcFormPasswordVisibilityIconShow" - | "kcFormSocialAccountLinkClass" - | "kcLocaleMainClass" - | "kcInputGroup" - | "kcTextareaClass" - | "kcButtonBlockClass" - | "kcButtonClass" - | "kcWebAuthnNFC" - | "kcLocaleClass" - | "kcInputClassCheckboxInput" - | "kcFeedbackErrorIcon" - | "kcInputLargeClass" - | "kcInputErrorMessageClass" - | "kcRecoveryCodesList" - | "kcFormSocialAccountListClass" - | "kcAlertTitleClass" - | "kcAuthenticatorPasswordClass" - | "kcCheckInputClass" - | "kcLogoIdP-linkedin" - | "kcLogoIdP-twitter" - | "kcFeedbackWarningIcon" - | "kcResetFlowIcon" - | "kcSelectAuthListItemIconPropertyClass" - | "kcFeedbackSuccessIcon" - | "kcLoginOTPListClass" - | "kcSrOnlyClass" - | "kcFormSocialAccountListGridClass" - | "kcButtonDefaultClass" - | "kcFormGroupErrorClass" - | "kcSelectAuthListItemDescriptionClass" - | "kcSelectAuthListItemBodyClass" - | "kcWebAuthnInternal" - | "kcSelectAuthListItemArrowClass" - | "kcCheckClass" - | "kcContentClass" - | "kcLogoClass" - | "kcLoginOTPListItemIconClass" - | "kcLoginClass" - | "kcSignUpClass" - | "kcButtonLargeClass" - | "kcFormCardClass" - | "kcLocaleListClass" - | "kcInputClass" - | "kcFormGroupClass" - | "kcLogoIdP-paypal" - | "kcInputClassCheckbox" - | "kcRecoveryCodesConfirmation" - | "kcFormPasswordVisibilityIconHide" - | "kcInputClassRadioInput" - | "kcFormSocialAccountListButtonClass" - | "kcInputClassCheckboxLabel" - | "kcFormOptionsClass" - | "kcFormHeaderClass" - | "kcFormSocialAccountGridItem" - | "kcButtonPrimaryClass" - | "kcInputHelperTextBeforeClass" - | "kcLogoIdP-github" - | "kcLabelClass"; +export type { ClassKey }; diff --git a/src/login/lib/kcClsx.ts b/src/login/lib/kcClsx.ts index a140671f..9198f778 100644 --- a/src/login/lib/kcClsx.ts +++ b/src/login/lib/kcClsx.ts @@ -1,5 +1,130 @@ import { createGetKcClsx } from "keycloakify/lib/getKcClsx"; -import type { ClassKey } from "keycloakify/login/TemplateProps"; + +export type ClassKey = + | "kcBodyClass" + | "kcHeaderWrapperClass" + | "kcLocaleWrapperClass" + | "kcInfoAreaWrapperClass" + | "kcFormButtonsWrapperClass" + | "kcFormOptionsWrapperClass" + | "kcCheckboxInputClass" + | "kcLocaleDropDownClass" + | "kcLocaleListItemClass" + | "kcContentWrapperClass" + | "kcLogoIdP-facebook" + | "kcAuthenticatorOTPClass" + | "kcLogoIdP-bitbucket" + | "kcAuthenticatorWebAuthnClass" + | "kcWebAuthnDefaultIcon" + | "kcLogoIdP-stackoverflow" + | "kcSelectAuthListItemClass" + | "kcLogoIdP-microsoft" + | "kcLoginOTPListItemHeaderClass" + | "kcLocaleItemClass" + | "kcLoginOTPListItemIconBodyClass" + | "kcInputHelperTextAfterClass" + | "kcFormClass" + | "kcSelectAuthListClass" + | "kcInputClassRadioCheckboxLabelDisabled" + | "kcSelectAuthListItemIconClass" + | "kcRecoveryCodesWarning" + | "kcFormSettingClass" + | "kcWebAuthnBLE" + | "kcInputWrapperClass" + | "kcSelectAuthListItemArrowIconClass" + | "kcFeedbackAreaClass" + | "kcFormPasswordVisibilityButtonClass" + | "kcLogoIdP-google" + | "kcCheckLabelClass" + | "kcSelectAuthListItemFillClass" + | "kcAuthenticatorDefaultClass" + | "kcLogoIdP-gitlab" + | "kcFormAreaClass" + | "kcFormButtonsClass" + | "kcInputClassRadioLabel" + | "kcAuthenticatorWebAuthnPasswordlessClass" + | "kcSelectAuthListItemHeadingClass" + | "kcInfoAreaClass" + | "kcLogoLink" + | "kcContainerClass" + | "kcSelectAuthListItemTitle" + | "kcHtmlClass" + | "kcLoginOTPListItemTitleClass" + | "kcLogoIdP-openshift-v4" + | "kcWebAuthnUnknownIcon" + | "kcFormSocialAccountNameClass" + | "kcLogoIdP-openshift-v3" + | "kcLoginOTPListInputClass" + | "kcWebAuthnUSB" + | "kcInputClassRadio" + | "kcWebAuthnKeyIcon" + | "kcFeedbackInfoIcon" + | "kcCommonLogoIdP" + | "kcRecoveryCodesActions" + | "kcFormGroupHeader" + | "kcFormSocialAccountSectionClass" + | "kcLogoIdP-instagram" + | "kcAlertClass" + | "kcHeaderClass" + | "kcLabelWrapperClass" + | "kcFormPasswordVisibilityIconShow" + | "kcFormSocialAccountLinkClass" + | "kcLocaleMainClass" + | "kcInputGroup" + | "kcTextareaClass" + | "kcButtonBlockClass" + | "kcButtonClass" + | "kcWebAuthnNFC" + | "kcLocaleClass" + | "kcInputClassCheckboxInput" + | "kcFeedbackErrorIcon" + | "kcInputLargeClass" + | "kcInputErrorMessageClass" + | "kcRecoveryCodesList" + | "kcFormSocialAccountListClass" + | "kcAlertTitleClass" + | "kcAuthenticatorPasswordClass" + | "kcCheckInputClass" + | "kcLogoIdP-linkedin" + | "kcLogoIdP-twitter" + | "kcFeedbackWarningIcon" + | "kcResetFlowIcon" + | "kcSelectAuthListItemIconPropertyClass" + | "kcFeedbackSuccessIcon" + | "kcLoginOTPListClass" + | "kcSrOnlyClass" + | "kcFormSocialAccountListGridClass" + | "kcButtonDefaultClass" + | "kcFormGroupErrorClass" + | "kcSelectAuthListItemDescriptionClass" + | "kcSelectAuthListItemBodyClass" + | "kcWebAuthnInternal" + | "kcSelectAuthListItemArrowClass" + | "kcCheckClass" + | "kcContentClass" + | "kcLogoClass" + | "kcLoginOTPListItemIconClass" + | "kcLoginClass" + | "kcSignUpClass" + | "kcButtonLargeClass" + | "kcFormCardClass" + | "kcLocaleListClass" + | "kcInputClass" + | "kcFormGroupClass" + | "kcLogoIdP-paypal" + | "kcInputClassCheckbox" + | "kcRecoveryCodesConfirmation" + | "kcFormPasswordVisibilityIconHide" + | "kcInputClassRadioInput" + | "kcFormSocialAccountListButtonClass" + | "kcInputClassCheckboxLabel" + | "kcFormOptionsClass" + | "kcFormHeaderClass" + | "kcFormSocialAccountGridItem" + | "kcButtonPrimaryClass" + | "kcInputHelperTextBeforeClass" + | "kcLogoIdP-github" + | "kcLabelClass"; export const { getKcClsx } = createGetKcClsx({ defaultClasses: { @@ -138,6 +263,4 @@ export const { getKcClsx } = createGetKcClsx({ } }); -export type { ClassKey }; - export type KcClsx = ReturnType["kcClsx"]; From 9af542ec89ef945bd561c52d7b515cf7c8de4803 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 01:10:45 +0200 Subject: [PATCH 04/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 42f474a3..f28b02d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.0", + "version": "11.2.1", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 835833a61bafe8d8919c13c8df49b7a11c8879e0 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 01:22:37 +0200 Subject: [PATCH 05/82] Remove unessesary reference to react specific construct in KcContext --- src/login/KcContext/KcContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/login/KcContext/KcContext.ts b/src/login/KcContext/KcContext.ts index 4b9c91e6..14d1169f 100644 --- a/src/login/KcContext/KcContext.ts +++ b/src/login/KcContext/KcContext.ts @@ -2,7 +2,7 @@ import type { ThemeType, LoginThemePageId } from "keycloakify/bin/shared/constan import type { ValueOf } from "keycloakify/tools/ValueOf"; import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; -import type { ClassKey } from "keycloakify/login/TemplateProps"; +import type { ClassKey } from "keycloakify/login/lib/kcClsx"; export type ExtendKcContext< KcContextExtension extends { properties?: Record }, From c84dc281a20f6d23595bc940b6fe574c9103e524 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 01:22:49 +0200 Subject: [PATCH 06/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f28b02d8..ef9b65d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.1", + "version": "11.2.2", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From ab0c281d9818bd2701e0967b24f7fbf640ecf3dd Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 11:48:57 +0200 Subject: [PATCH 07/82] Fix allegated vulnerability --- .../keycloakify/generateFtl/kcContextDeclarationTemplate.ftl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl b/src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl index 6b74955f..5cfc2755 100644 --- a/src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl +++ b/src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl @@ -166,7 +166,7 @@ function decodeHtmlEntities(htmlStr){ areSamePath(path, []) && ["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId) ) || ( - ["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) && + ["masterAdminClient", "delegateForUpdate", "defaultRole", "smtpConfig"]?seq_contains(key) && areSamePath(path, ["realm"]) ) || ( xKeycloakify.pageId == "error.ftl" && From 613167f3a690c77883dbf6a7b9f1dfeca928c493 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 11:49:33 +0200 Subject: [PATCH 08/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef9b65d9..880782d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.2", + "version": "11.2.3", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From d6436a58a2cc381a9cd17e4f39a6a16806a816f6 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 17:57:41 +0200 Subject: [PATCH 09/82] update ci --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9fca0a23..6ec87504 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -112,7 +112,7 @@ jobs: registry-url: https://registry.npmjs.org/ - uses: bahmutov/npm-install@v1 - run: npm run build - - run: npx -y -p denoify@1.6.12 enable_short_npm_import_path + - run: npx -y -p denoify@1.6.13 enable_short_npm_import_path env: DRY_RUN: "0" - uses: garronej/ts-ci@v2.1.2 From 9c44d13f73b147a01a87696b77a7d3a3bec42e87 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 18:10:09 +0200 Subject: [PATCH 10/82] Update tsafe (provide ESM distribution) --- package.json | 2 +- yarn.lock | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 880782d1..4b2ad41f 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ ], "homepage": "https://www.keycloakify.dev", "dependencies": { - "tsafe": "^1.6.6" + "tsafe": "^1.7.4" }, "devDependencies": { "@babel/core": "^7.24.5", diff --git a/yarn.lock b/yarn.lock index abab9a2c..d05e77af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12579,6 +12579,11 @@ tsafe@^1.6.6: resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.6.6.tgz#fd93e64d6eb13ef83ed1650669cc24bad4f5df9f" integrity sha512-gzkapsdbMNwBnTIjgO758GujLCj031IgHK/PKr2mrmkCSJMhSOR5FeOuSxKLMUoYc0vAA4RGEYYbjt/v6afD3g== +tsafe@^1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.7.4.tgz#7dd288b1a1be8d9c25e84ab8dd8a7df6094168d7" + integrity sha512-4BrLklZMJ14dEtA+CkhY9OtID3al4+/GJhaeocWPtUuoZPr4SJkaqoPemyFgkLC1Y3LRNXF9zxa94SwssRGMaQ== + tsc-alias@^1.8.10: version "1.8.10" resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.8.10.tgz#279f9bf0dd8bc10fb27820393d4881db5a303938" From 2fd04cfb6165c89feb8019aa7baaf50a63669cb3 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 30 Sep 2024 18:10:26 +0200 Subject: [PATCH 11/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4b2ad41f..2aac84f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.3", + "version": "11.2.4", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 7203c742bef82ea1a120f5a8fb22db56dbbf6c58 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Tue, 1 Oct 2024 11:52:40 +0200 Subject: [PATCH 12/82] Avoid modifying BASE_URL for App context --- src/vite-plugin/vite-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vite-plugin/vite-plugin.ts b/src/vite-plugin/vite-plugin.ts index 1ccfaf89..f22cb62a 100644 --- a/src/vite-plugin/vite-plugin.ts +++ b/src/vite-plugin/vite-plugin.ts @@ -166,7 +166,7 @@ export function keycloakify(params: keycloakify.Params) { [ `(`, `(window.kcContext === undefined || import.meta.env.MODE === "development")?`, - `"${urlPathname ?? "/"}":`, + `import.meta.env.BASE_URL:`, `(window.kcContext["x-keycloakify"].resourcesPath + "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/")`, `)` ].join("") From 810dc6ceb5877cd9d1a08f1f17954caf923439cc Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Tue, 1 Oct 2024 11:59:39 +0200 Subject: [PATCH 13/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2aac84f8..760e7453 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.4", + "version": "11.2.5", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From a0e3dc163a587e856762b983e11c8de8304ea0cc Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 08:59:48 +0000 Subject: [PATCH 14/82] docs: update README.md [skip ci] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ddc36c80..82e44eba 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Omid
Omid

⚠️ 💻 + Katharina Eiserfey
Katharina Eiserfey

💻 ⚠️ 📖 From 1f2a755a97645ab8c2e935b235f01926334c0547 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 08:59:49 +0000 Subject: [PATCH 15/82] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 864f118b..b6496bfb 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -269,6 +269,17 @@ "test", "code" ] + }, + { + "login": "kathari00", + "name": "Katharina Eiserfey", + "avatar_url": "https://avatars.githubusercontent.com/u/42547712?v=4", + "profile": "https://github.com/kathari00", + "contributions": [ + "code", + "test", + "doc" + ] } ], "contributorsPerLine": 7, From 6c4dc711d21689ddbdb8ea531c49806c85d76226 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Wed, 2 Oct 2024 11:02:25 +0200 Subject: [PATCH 16/82] Put Kathi as first contributor --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 82e44eba..50c221ce 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,13 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d + - @@ -134,7 +134,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - +
Katharina Eiserfey
Katharina Eiserfey

💻 ⚠️ 📖
Waldemar Reusch
Waldemar Reusch

💻
William Will
William Will

💻
Bystrova Ann
Bystrova Ann

💻
Michael Kreuzmayr
Michael Kreuzmayr

💻
Mary
Mary

💻
German Öö
German Öö

💻
Julien Bouquillon
Julien Bouquillon

💻
Aidan Gilmore
Aidan Gilmore

💻
Omid
Omid

⚠️ 💻
Katharina Eiserfey
Katharina Eiserfey

💻 ⚠️ 📖
Julien Bouquillon
Julien Bouquillon

💻
From fa934da4429483607b13384c7f0cbbbdf94b49d6 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 09:05:34 +0000 Subject: [PATCH 17/82] docs: update README.md [skip ci] --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 50c221ce..78872df3 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,13 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - + @@ -134,7 +134,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - + +
Katharina Eiserfey
Katharina Eiserfey

💻 ⚠️ 📖
Waldemar Reusch
Waldemar Reusch

💻
William Will
William Will

💻
Bystrova Ann
Bystrova Ann

💻
Michael Kreuzmayr
Michael Kreuzmayr

💻
Mary
Mary

💻
German Öö
German Öö

💻
Julien Bouquillon
Julien Bouquillon

💻
Aidan Gilmore
Aidan Gilmore

💻
Omid
Omid

⚠️ 💻
Julien Bouquillon
Julien Bouquillon

💻
Katharina Eiserfey
Katharina Eiserfey

💻 ⚠️ 📖
Luca Peruzzo
Luca Peruzzo

💻 ⚠️
From 87198f6e568aaa85e61319a99aeefaf3b7648432 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 09:05:35 +0000 Subject: [PATCH 18/82] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index b6496bfb..fdede216 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -280,6 +280,16 @@ "test", "doc" ] + }, + { + "login": "luca-peruzzo", + "name": "Luca Peruzzo", + "avatar_url": "https://avatars.githubusercontent.com/u/69015314?v=4", + "profile": "https://github.com/luca-peruzzo", + "contributions": [ + "code", + "test" + ] } ], "contributorsPerLine": 7, From 8565eb3fb8b09dddb16133d124cf19862e829d27 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Wed, 2 Oct 2024 13:42:38 +0200 Subject: [PATCH 19/82] Update tsafe --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 760e7453..14a5622b 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ ], "homepage": "https://www.keycloakify.dev", "dependencies": { - "tsafe": "^1.7.4" + "tsafe": "^1.7.5" }, "devDependencies": { "@babel/core": "^7.24.5", diff --git a/yarn.lock b/yarn.lock index d05e77af..c8b469f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12579,10 +12579,10 @@ tsafe@^1.6.6: resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.6.6.tgz#fd93e64d6eb13ef83ed1650669cc24bad4f5df9f" integrity sha512-gzkapsdbMNwBnTIjgO758GujLCj031IgHK/PKr2mrmkCSJMhSOR5FeOuSxKLMUoYc0vAA4RGEYYbjt/v6afD3g== -tsafe@^1.7.4: - version "1.7.4" - resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.7.4.tgz#7dd288b1a1be8d9c25e84ab8dd8a7df6094168d7" - integrity sha512-4BrLklZMJ14dEtA+CkhY9OtID3al4+/GJhaeocWPtUuoZPr4SJkaqoPemyFgkLC1Y3LRNXF9zxa94SwssRGMaQ== +tsafe@^1.7.5: + version "1.7.5" + resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.7.5.tgz#0d3a31202b5ef87c7ba997e66e03fd80801278ef" + integrity sha512-tbNyyBSbwfbilFfiuXkSOj82a6++ovgANwcoqBAcO9/REPoZMEQoE8kWPeO0dy5A2D/2Lajr8Ohue5T0ifIvLQ== tsc-alias@^1.8.10: version "1.8.10" From 7241f0c741bc2fc5d3db2cb1b93902b4652c364b Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Wed, 2 Oct 2024 13:44:22 +0200 Subject: [PATCH 20/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 14a5622b..e6c02daa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.5", + "version": "11.2.6", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 80d8a0c4e399eba7201c623bb338bc04dcc607a5 Mon Sep 17 00:00:00 2001 From: johanjk Date: Wed, 2 Oct 2024 16:16:16 +0200 Subject: [PATCH 21/82] ['select-radiobuttons'/'multiselect-checkboxes'] fixed 'inputOptionLabels' --- src/login/UserProfileFormFields.tsx | 38 ++++++++++++------------ stories/login/pages/Register.stories.tsx | 32 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/login/UserProfileFormFields.tsx b/src/login/UserProfileFormFields.tsx index 445bda6f..93a99ad7 100644 --- a/src/login/UserProfileFormFields.tsx +++ b/src/login/UserProfileFormFields.tsx @@ -434,9 +434,7 @@ function AddRemoveButtonsMultiValuedAttribute(props: { } function InputTagSelects(props: InputFieldByTypeProps) { - const { attribute, dispatchFormAction, kcClsx, valueOrValues } = props; - - const { advancedMsg } = props.i18n; + const { attribute, dispatchFormAction, kcClsx, i18n, valueOrValues } = props; const { classDiv, classInput, classLabel, inputType } = (() => { const { inputType } = attribute.annotations; @@ -533,7 +531,7 @@ function InputTagSelects(props: InputFieldByTypeProps) { htmlFor={`${attribute.name}-${option}`} className={`${classLabel}${attribute.readOnly ? ` ${kcClsx("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`} > - {advancedMsg(option)} + {inputLabel(i18n, attribute, option)} ))} @@ -580,8 +578,6 @@ function TextareaTag(props: InputFieldByTypeProps) { function SelectTag(props: InputFieldByTypeProps) { const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props; - const { advancedMsgStr } = i18n; - const isMultiple = attribute.annotations.inputType === "multiselect"; return ( @@ -645,22 +641,26 @@ function SelectTag(props: InputFieldByTypeProps) { return options.map(option => ( )); })()} ); } + +function inputLabel(i18n: I18n, attribute: Attribute, option: string) { + const { advancedMsg } = i18n; + + if (attribute.annotations.inputOptionLabels !== undefined) { + const { inputOptionLabels } = attribute.annotations; + + return advancedMsg(inputOptionLabels[option] ?? option); + } + + if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) { + return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`); + } + + return option; +} diff --git a/stories/login/pages/Register.stories.tsx b/stories/login/pages/Register.stories.tsx index 0a8c395f..f25568cc 100644 --- a/stories/login/pages/Register.stories.tsx +++ b/stories/login/pages/Register.stories.tsx @@ -115,6 +115,38 @@ export const WithFavoritePet: Story = { ) }; + +export const WithNewsletter: Story = { + render: () => ( + + ) +}; + + export const WithEmailAsUsername: Story = { render: () => ( Date: Wed, 2 Oct 2024 23:36:58 +0200 Subject: [PATCH 22/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6c02daa..ff7fbe2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.6", + "version": "11.2.8", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From ec297249974386d9d62f9ea422b1a718a375ba20 Mon Sep 17 00:00:00 2001 From: pnzrr <93841792+pnzrr@users.noreply.github.com> Date: Thu, 3 Oct 2024 21:04:02 -0600 Subject: [PATCH 23/82] Fix link in CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 733042ec..25b079a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,3 @@ Looking to contribute? Thank you! PR are more than welcome. -Please refers to [this documentation page](https://docs.keycloakify.dev/contributing) that will help you get started. +Please refers to [this documentation page](https://docs.keycloakify.dev/faq-and-help/contributing) that will help you get started. From 908e083deeb3d7b0f867957a2e1a4bdee2f2903b Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 4 Oct 2024 12:17:08 +0200 Subject: [PATCH 24/82] Update version target range --- .../getKeycloakVersionRangeForJar.ts | 4 +- src/bin/shared/KeycloakVersionRange.ts | 2 +- src/bin/shared/buildContext.ts | 36 +- src/bin/start-keycloak/myrealm-realm-26.json | 2400 +++++++++++++++++ 4 files changed, 2431 insertions(+), 11 deletions(-) create mode 100644 src/bin/start-keycloak/myrealm-realm-26.json diff --git a/src/bin/keycloakify/buildJars/getKeycloakVersionRangeForJar.ts b/src/bin/keycloakify/buildJars/getKeycloakVersionRangeForJar.ts index 3999ce8f..6c301223 100644 --- a/src/bin/keycloakify/buildJars/getKeycloakVersionRangeForJar.ts +++ b/src/bin/keycloakify/buildJars/getKeycloakVersionRangeForJar.ts @@ -75,9 +75,9 @@ export function getKeycloakVersionRangeForJar(params: { } switch (keycloakThemeAdditionalInfoExtensionVersion) { case null: - return "21-and-below"; + return "all-other-versions"; case "1.1.5": - return "22-and-above"; + return "22-to-25"; } assert>( false diff --git a/src/bin/shared/KeycloakVersionRange.ts b/src/bin/shared/KeycloakVersionRange.ts index 2827d34e..1e118ea1 100644 --- a/src/bin/shared/KeycloakVersionRange.ts +++ b/src/bin/shared/KeycloakVersionRange.ts @@ -3,7 +3,7 @@ export type KeycloakVersionRange = | KeycloakVersionRange.WithoutAccountV1Theme; export namespace KeycloakVersionRange { - export type WithoutAccountV1Theme = "21-and-below" | "22-and-above"; + export type WithoutAccountV1Theme = "22-to-25" | "all-other-versions"; export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25-and-above"; } diff --git a/src/bin/shared/buildContext.ts b/src/bin/shared/buildContext.ts index b4a998b7..852bd1fd 100644 --- a/src/bin/shared/buildContext.ts +++ b/src/bin/shared/buildContext.ts @@ -25,6 +25,7 @@ import { type ThemeType } from "./constants"; import { id } from "tsafe/id"; import chalk from "chalk"; import { getProxyFetchOptions, type ProxyFetchOptions } from "../tools/fetchProxyOptions"; +import { is } from "tsafe/is"; export type BuildContext = { themeVersion: string; @@ -297,8 +298,8 @@ export function getBuildContext(params: { ]), keycloakVersionTargets: z .object({ - "21-and-below": z.union([z.boolean(), z.string()]), - "22-and-above": z.union([z.boolean(), z.string()]) + "22-to-25": z.union([z.boolean(), z.string()]), + "all-other-versions": z.union([z.boolean(), z.string()]) }) .optional() }); @@ -779,11 +780,14 @@ export function getBuildContext(params: { return keycloakVersionRange; } else { const keycloakVersionRange = (() => { - if (buildForKeycloakMajorVersionNumber <= 21) { - return "21-and-below" as const; + if ( + buildForKeycloakMajorVersionNumber <= 21 || + buildForKeycloakMajorVersionNumber >= 26 + ) { + return "all-other-versions" as const; } - return "22-and-above" as const; + return "22-to-25" as const; })(); assert< @@ -801,6 +805,12 @@ export function getBuildContext(params: { use_custom_jar_basename: { const { keycloakVersionTargets } = buildOptions; + assert( + is>( + keycloakVersionTargets + ) + ); + if (keycloakVersionTargets === undefined) { break use_custom_jar_basename; } @@ -861,8 +871,8 @@ export function getBuildContext(params: { } } else { for (const keycloakVersionRange of [ - "21-and-below", - "22-and-above" + "22-to-25", + "all-other-versions" ] as const) { assert< Equals< @@ -888,7 +898,17 @@ export function getBuildContext(params: { const jarTargets: BuildContext["jarTargets"] = []; for (const [keycloakVersionRange, jarNameOrBoolean] of objectEntries( - buildOptions.keycloakVersionTargets + (() => { + const { keycloakVersionTargets } = buildOptions; + + assert( + is>( + keycloakVersionTargets + ) + ); + + return keycloakVersionTargets; + })() )) { if (jarNameOrBoolean === false) { continue; diff --git a/src/bin/start-keycloak/myrealm-realm-26.json b/src/bin/start-keycloak/myrealm-realm-26.json new file mode 100644 index 00000000..21b95023 --- /dev/null +++ b/src/bin/start-keycloak/myrealm-realm-26.json @@ -0,0 +1,2400 @@ +{ + "id": "5d0dd960-0478-4ca6-b64a-810a3f6f4071", + "realm": "myrealm", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "cc4b5045-3bff-4aa7-889e-1492630c3002", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "5d0dd960-0478-4ca6-b64a-810a3f6f4071", + "attributes": {} + }, + { + "id": "e92017b2-18a0-49cd-956c-fad64f16b26b", + "name": "default-roles-myrealm", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": ["offline_access", "uma_authorization"], + "client": { + "account": ["delete-account", "manage-account", "view-profile"] + } + }, + "clientRole": false, + "containerId": "5d0dd960-0478-4ca6-b64a-810a3f6f4071", + "attributes": {} + }, + { + "id": "e8616113-e302-4abe-bd5c-d51f8221046b", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "5d0dd960-0478-4ca6-b64a-810a3f6f4071", + "attributes": {} + } + ], + "client": { + "myclient": [], + "realm-management": [ + { + "id": "b27b272d-d153-4ae7-9fe7-fd96582f057d", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "40fdfec8-f1b9-4c2b-81c5-a775bc047840", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "5f446f9a-d008-4067-8325-f4658a32d964", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "82bf956d-1fd1-4d20-a5a9-62b3e77e9d88", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "b41e1ce8-d63f-4cf4-9966-e6c9eab5da11", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "3198743d-fdfa-4a9c-a229-5fb979847ec2", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-users", "query-groups"] + } + }, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "e83c21cb-c84c-4824-9f7d-ce3574921800", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "3f6e2e81-e40d-40ff-a5f3-12ba2614fba5", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "63111288-7f3d-4570-838f-48405d70e212", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "a7f8f8ad-057b-485e-abfa-8a98e5e0c4ea", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "7783b160-2f1a-48c9-89fb-623a29f26c9a", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "b8b5341f-f44f-40a2-9ba4-e2d621b11b2f", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "6b9d72e9-949f-4897-b11a-c8aa9252f3f2", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "bfa94ba9-1d70-4259-b928-906e8bb815b2", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "96bb9322-5c1f-48f0-aa05-65521c77e742", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "manage-users", + "view-authorization", + "manage-events", + "create-client", + "view-users", + "manage-clients", + "query-users", + "query-groups", + "view-realm", + "manage-realm", + "query-realms", + "query-clients", + "impersonation", + "view-events", + "manage-authorization", + "manage-identity-providers", + "view-identity-providers", + "view-clients" + ] + } + }, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "6e0ca5ce-f5db-4580-90e5-27c35804fc34", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "7499eb46-cf4a-4813-9bf9-42b1bbcadc0d", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "fcc99ef9-347d-4c21-b25c-8229e906a1a3", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + }, + { + "id": "7b024069-57d8-4368-9942-8790507c156d", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "3050eb8a-9a47-4a27-aece-be2e60fc7f73", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "f5e032da-c8ab-48c2-959c-8466ad1e6a09", + "attributes": {} + } + ], + "account": [ + { + "id": "d554d15b-d098-47a0-bdd5-d656b20f5643", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "7221ef76-9d96-49ad-88a6-9f72eeeb0aa7", + "attributes": {} + }, + { + "id": "aaf4946d-2cd4-43ba-ad7d-86be56b9ad2c", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "7221ef76-9d96-49ad-88a6-9f72eeeb0aa7", + "attributes": {} + }, + { + "id": "b417b187-18b7-41fa-9537-3313cf9b8ed4", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": true, + "containerId": "7221ef76-9d96-49ad-88a6-9f72eeeb0aa7", + "attributes": {} + }, + { + "id": "8bb5480d-83a3-4ea2-8e91-237b8870acec", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "7221ef76-9d96-49ad-88a6-9f72eeeb0aa7", + "attributes": {} + }, + { + "id": "e341c1b8-eaf7-467d-9986-d3f2356a60b9", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "7221ef76-9d96-49ad-88a6-9f72eeeb0aa7", + "attributes": {} + }, + { + "id": "98ccac20-3906-436f-8dc3-ae8d8ae25cbc", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "7221ef76-9d96-49ad-88a6-9f72eeeb0aa7", + "attributes": {} + }, + { + "id": "adfba539-826f-4fa7-86f5-8c1287152ed6", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "7221ef76-9d96-49ad-88a6-9f72eeeb0aa7", + "attributes": {} + }, + { + "id": "2516ab58-490c-444c-9e7d-0dd8b87a69f0", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": true, + "containerId": "7221ef76-9d96-49ad-88a6-9f72eeeb0aa7", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "e92017b2-18a0-49cd-956c-fad64f16b26b", + "name": "default-roles-myrealm", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "5d0dd960-0478-4ca6-b64a-810a3f6f4071" + }, + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": { + "de": { + "profile.attributes.favourite_pet": "" + }, + "no": { + "profile.attributes.favourite_pet": "" + }, + "fi": { + "profile.attributes.favourite_pet": "" + }, + "ru": { + "profile.attributes.favourite_pet": "" + }, + "pt": { + "profile.attributes.favourite_pet": "" + }, + "lt": { + "profile.attributes.favourite_pet": "" + }, + "lv": { + "profile.attributes.favourite_pet": "" + }, + "fr": { + "profile.attributes.favourite_pet": "Animal de compagnie préféré", + "profile.attributes.favourite_pet.cat": "Chat", + "profile.attributes.favourite_pet.dog": "Chien", + "profile.attributes.favourite_pet.bird": "Oiseau" + }, + "hu": { + "profile.attributes.favourite_pet": "" + }, + "zh-CN": { + "profile.attributes.favourite_pet": "" + }, + "uk": { + "profile.attributes.favourite_pet": "" + }, + "sk": { + "profile.attributes.favourite_pet": "" + }, + "ca": { + "profile.attributes.favourite_pet": "" + }, + "sv": { + "profile.attributes.favourite_pet": "" + }, + "zh-TW": { + "profile.attributes.favourite_pet": "" + }, + "pt-BR": { + "profile.attributes.favourite_pet": "" + }, + "en": { + "profile.attributes.favourite_pet": "Favourite Pet", + "profile.attributes.favourite_pet.cat": "Cat", + "profile.attributes.favourite_pet.dog": "Dog", + "profile.attributes.favourite_pet.bird": "Bird" + }, + "it": { + "profile.attributes.favourite_pet": "" + }, + "es": { + "profile.attributes.favourite_pet": "Mascota favorita", + "profile.attributes.favourite_pet.cat": "Gato", + "profile.attributes.favourite_pet.dog": "Perro", + "profile.attributes.favourite_pet.bird": "Pájaro" + }, + "cs": { + "profile.attributes.favourite_pet": "" + }, + "ar": { + "profile.attributes.favourite_pet": "" + }, + "th": { + "profile.attributes.favourite_pet": "" + }, + "ja": { + "profile.attributes.favourite_pet": "" + }, + "fa": { + "profile.attributes.favourite_pet": "" + }, + "pl": { + "profile.attributes.favourite_pet": "" + }, + "da": { + "profile.attributes.favourite_pet": "" + }, + "nl": { + "profile.attributes.favourite_pet": "" + }, + "tr": { + "profile.attributes.favourite_pet": "" + } + }, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "d93e1772-4916-4243-850f-a6d9b2615716", + "username": "testuser", + "firstName": "Test", + "lastName": "User", + "email": "testuser@gmail.com", + "emailVerified": true, + "attributes": { + "additional_emails": ["test.user@protonmail.com", "testuser@hotmail.com"], + "gender": ["prefer_not_to_say"], + "favorite_pet": ["cats"], + "favourite_pet": ["cat"], + "bio": ["Hello I'm Test User and I do not exist."], + "phone_number": ["1111111111"], + "locale": ["en"], + "favorite_media": ["movies", "series"] + }, + "createdTimestamp": 1716183898408, + "enabled": true, + "totp": false, + "credentials": [ + { + "id": "576982e2-6fb3-4752-8724-5ff390ea8301", + "type": "password", + "userLabel": "My password", + "createdDate": 1716183916529, + "secretData": "{\"value\":\"9hwJ989FAr0UgT0MfffNYSI6Zf/3qT/y17DTUcwbiEM=\",\"salt\":\"C3ZnHzgPd+0Lemw4olCOgA==\",\"additionalParameters\":{}}", + "credentialData": "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["default-roles-myrealm"], + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account", "view-groups"] + } + ] + }, + "clients": [ + { + "id": "7221ef76-9d96-49ad-88a6-9f72eeeb0aa7", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/myrealm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/myrealm/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "d8f14dc4-5f0f-4a1d-8c0b-cfe78ee55cb3", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/myrealm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/myrealm/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "08d7bc08-2ff3-44ea-9d65-fa1c4ca35646", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "953c597f-faef-4abc-88dc-4fbc9501170c", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "f5e032da-c8ab-48c2-959c-8466ad1e6a09", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "8fba88fa-61e9-45a4-893d-ab102973ebf6", + "clientId": "myclient", + "name": "", + "description": "", + "rootUrl": "https://my-theme.keycloakify.dev", + "adminUrl": "https://my-theme.keycloakify.dev", + "baseUrl": "https://my-theme.keycloakify.dev", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "https://my-theme.keycloakify.dev/*", + "http://localhost*", + "http://127.0.0.1*" + ], + "webOrigins": ["*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "login_theme": "keycloakify-starter", + "post.logout.redirect.uris": "https://my-theme.keycloakify.dev/*##http://localhost*##http://127.0.0.1*", + "oauth2.device.authorization.grant.enabled": "false", + "display.on.consent.screen": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "91a196c1-f93c-48a5-aced-b8d60fb09b62", + "name": "Favourite Pet", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "favourite_pet", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "favourite_pet", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e05cc68c-5e53-4796-ae3a-a1bfbf5c51bb", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "fce8a109-6f32-4814-9a20-2ff2435d2da6", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/myrealm/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/admin/myrealm/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "52192d19-0406-41b7-b995-b099bdbaa448", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "6a955b1e-f0e2-49fa-b3c9-bd59ed1fcd4f", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "3a392f70-ed70-424a-b60b-82db32b83df8", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "9cda058d-9935-4c8b-844d-c163d10f7c3c", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "a053d8ec-b267-4e5a-a424-3b14bef9cd15", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "6225f4c7-ad5c-42ea-b7d4-5bb4e7c77459", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "5052be82-243f-41b0-a214-4f01935180e5", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "4d31d278-e6ef-4b8b-97cb-4da9626d0e93", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "9357440c-6200-41a1-a447-0ec97895763e", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "bf9cb6c6-71a4-4bf9-8c60-ed58adcc2258", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + }, + { + "id": "679c8292-1abb-4d96-bacc-671303765f9b", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "0ec225e7-253b-4a01-85e1-68daf3df3eba", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "a55cf74e-ce68-4ebd-9c24-dc3fd6a9cfa5", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "e2f1dd86-00a2-4374-b888-7211f748c58d", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "e86456b8-0663-448e-ad16-7d520d0c448e", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "569c799d-79f2-4b2b-a1ec-3661e3d8d433", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "2d01eb48-77c3-4c83-a864-755699cb7e7c", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "a9700270-006f-4a85-8458-f39644659029", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "3a7bca96-0839-4d1e-b37d-6e624f37facb", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "2a41be1c-872a-4b3e-9051-71ebd5d140c1", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "9fe5e57d-ee79-4b8b-9ab2-345093a1fdbf", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "bda9e4e7-4de0-455d-bace-4e94b1dab5ad", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "312a0b4d-46b8-42e0-b162-e5869b317b36", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "4f8ac9bc-e32d-4ebb-bb85-b9a94a459aa1", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "bebdf0c7-6f0f-4b08-a327-50af837c82b9", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "d96d9686-f4e0-479a-9855-cfc526a35294", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "66ad8239-e1df-4f9d-9cb7-d35f23f95f37", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "ece8245b-16ae-4322-bc78-f8d5f671640a", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "384cf049-0fed-47e2-8b11-06cf6c03465d", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "49e85de9-edd1-4a9e-a2b0-e9c663d4dd9a", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "d458e6fc-b414-4b45-b9e1-99342d7d2bba", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "2b73ce63-0443-46dc-b35c-1148edb976ab", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "71303f6d-348a-4892-9d6f-dc9a2d2e4b14", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "498cbff6-a650-4a09-8192-5defaa50f33b", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "eb8585bc-ca30-410e-9f92-0d63665f5ed6", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "62b8c264-2c10-48c6-803f-b7606a89e0d9", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "0c18ca55-df63-4071-81f9-43f5d077c015", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "6de6510d-d7f3-4289-a10f-4c21289313a4", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "a5851eb2-bfc5-4a0a-8a49-92f4fc8c5041", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "bfc69775-83af-4816-82fd-d1c42687fb5e", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "8e2027d5-32dd-4a87-a7ec-00e5316c5617", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "loginTheme": "keycloakify-starter", + "accountTheme": "keycloakify-starter", + "adminTheme": "", + "emailTheme": "", + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "67526992-f0ce-42ff-a0fb-af267192ff70", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "64a2f718-da10-45d9-a75a-69c156a7ccd8", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "saml-user-property-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper" + ] + } + }, + { + "id": "4d3e104f-6fdf-45eb-b756-5fef6840fbed", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "c647e85f-6700-4d66-84f2-4a869e467735", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "51f41974-f7e5-4e7d-b486-5bd652a98e93", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper", + "oidc-address-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-full-name-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "saml-role-list-mapper" + ] + } + }, + { + "id": "8f7d6ece-e956-4e48-95ab-5ab72b2b7c9a", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "e60b1167-cdee-4173-be99-3dad6a536b4a", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + }, + { + "id": "5ba8b893-ab01-430b-9092-32646a50a662", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "237022c6-9443-46b3-902e-210e14c3c9a8", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"favourite_pet\",\"displayName\":\"${profile.attributes.favourite_pet}\",\"validations\":{\"options\":{\"options\":[\"cat\",\"dog\",\"bird\"]}},\"annotations\":{\"inputType\":\"select\",\"inputOptionLabelsI18nPrefix\":\"profile.attributes.favourite_pet\"},\"required\":{\"roles\":[\"admin\",\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "5f3c1765-8810-419f-9c18-4a2db0e874e7", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEowIBAAKCAQEAsYUWzVfZMd6ywpBmLJYeF1U9Mgd/z3xWvl1Yq76oRPPfpcqQitN+cktWqu0hPerCVSl2ltwXDMrUwFzswG9MiM9hb+BLEld7kYiYkcFNt3lCtmmeRQEae7JwWimzeNV96Qlz0tHY8f9Zh0ffPDsLTN1HGAeRJJhI7mNQm6qCJNMCfVA/O5SWumsIn2XLnSMiQ05AACVHOLUq6rAZ2zCCaYmXTmJkuSOb8e26V303P6l63DSe5HSNXDdI00tjfFFf37q870zhvfsotrjjx0RMijy9Kjj8OZF+pFHpDRaGEi8tpQxZDnCTofTieB/Vp3QP+aTlvAyD3Q1ZnJxGQCLygwIDAQABAoIBABUJ9XMJGNQzamiVwuOWN7ht4UP8ezYvgdEA8NaLUO0PIYVIKyD7l4OwkHPPM9PfRACM2qG0MZp8sCyg4WxIeepy+D979oRqJYUmNRLSipqWlASuItRXIPjiY99uYXdjh2R8Os5pvCD+MZxPX9KHGuaVXmzSJMO7YAAPeYkMHcLYTp/U0c65Ztaaz1zz1FeyvpjkLr9SHiMcIN51zFmhvT1tcRIqy4zidisjrTSUr/KPVxeJtrEfyhTGk3z41yJf5YbeaxaMjJR5x0WXzt1fWVmA/V1bWa2Zlj9d8AxDReA1p7Lpstz34PRoCMj9bmFguI2+RTw6K0D++Jydfxmh8vUCgYEA5Zwk2r3TFO3i3V70LOn6CLzn15yLeuSIJ9p2os70jQOmFMCreLdcUbCaiUe7UV/IIVftbcxhFm9zECXZXX0wubcmHZqyptlbuAn1de4QkLJixXo1A7ZQXBEZk22WN2naXHQF5oK6lh/VSLcZBajTsyvBm5JWXrd8djjG06MugA8CgYEAxexKI5IwcLhpMDV9UPQb/+lDWHVqCT2xwYxnZ85y+5gmrOyyT7mIChz3DFYiaw4CHJWmBkIDBaiDgLEgQk4QXWzYshXawShBHnv1h08bVMMw98Ivec7ZRkV+/ET30YRwC2Uyk4bm4HpwVV5GCFhC4aAvRcCA1CIJk3MwcOwksk0CgYEAqxyaOomMbOR7VQ4WWgJkW26sOHppV8RH06tzDhG9HfnCI2USZHwBSL+b6wKSDiqbMn4cat8M23NjBH2wZ4OMdFqRBS7sRHtnZtfFHYW0wqCuCwzvxTxw1qvHq57Xe6RfHtc4LnjuJELE59PLyfPvEG9jcVS1GREUp+XYBpBtbvECgYAMhWBDU9JAr0noRNoCrw6+Z9Fc3UCyCPcf2XQJOyRHCl8X/XliVchna2GtpB1VTHORv13bc32hdAGtuIbj6vBaGLK0wXEvWw6TkR/9SWHfQOHuKpi6Sf2w1mCsMOjElm5IKkTC1Hvyo4xLukUP7hV9FJcpAH6l7OlSLK1Z13aS2QKBgB6w4gvmVEQruHV5+K60OatuFojr+kxJwmzCb5uKOULUFezT2pA3p3l6IWxGL2XtM+LD0SiZE3KZJUzf+LatYlBU9ek4F1krkVNUTRZpzUa0oADbymCL1chM4oPIs7sISQlFIH2wOSZt6Blvcw0E0wfjd9Gv/LHxcMnlRb1t1sLk" + ], + "keyUse": ["SIG"], + "certificate": [ + "MIICnTCCAYUCBgGQBsyplzANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdteXJlYWxtMB4XDTI0MDYxMTEwMTQ1NFoXDTM0MDYxMTEwMTYzNFowEjEQMA4GA1UEAwwHbXlyZWFsbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALGFFs1X2THessKQZiyWHhdVPTIHf898Vr5dWKu+qETz36XKkIrTfnJLVqrtIT3qwlUpdpbcFwzK1MBc7MBvTIjPYW/gSxJXe5GImJHBTbd5QrZpnkUBGnuycFops3jVfekJc9LR2PH/WYdH3zw7C0zdRxgHkSSYSO5jUJuqgiTTAn1QPzuUlrprCJ9ly50jIkNOQAAlRzi1KuqwGdswgmmJl05iZLkjm/Htuld9Nz+petw0nuR0jVw3SNNLY3xRX9+6vO9M4b37KLa448dETIo8vSo4/DmRfqRR6Q0WhhIvLaUMWQ5wk6H04ngf1ad0D/mk5bwMg90NWZycRkAi8oMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAVS+gJshIFX6cmBGI8UaOOI/9+XFb4Gi+DHaHVWVVHTd14MoqNK1bmmyTHbGIZbvK8UqgJ9+FhJX1ejx17d4KBzkZI3tYvPnVacHvaw1CIUMZ1Ini6u+UGUTnIlnQzCG0pcTKjOZXf3ih1B2CKdwyC7XeXyEJHicAIG7XfzYfYd9DYHvA+h6hrXaQcNJMW7WFNbtb3fJhtlv5P1Iw+ZEGdj15ukMI0bg2OEQA0F3jIw6QZpigSAGuai3HOY6OgoPO82d7TyTYlNhuwyutWr9izl6QMc2R7BmRfW9XQj4ICR2VWJiL9nqz+SOyqnjQiOObuw8Vywb8c36R1Ym1aaGjOw==" + ], + "priority": ["100"] + } + }, + { + "id": "e586f825-a25a-4833-a38e-4c6484ad17fd", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEogIBAAKCAQEAkQtefHy82e8d5dVWN00LnGI5YmBOTKh0tgqayVRjqLH6u3NfgJVVIe0tFnxa7Wka/ySHrn1KSsW52czZ4uPXLUo4sXBkQxyyFXeZiWN8H+9WiUQ+0hefZF4es5ZPhY2VpeMK9XAnphC362LFLVycXulkpJcQ+4DjI99To4LLyJmjQvsVaJ7amoVJ5xd62eUv+D7f2+jwuaTwjGE3+MWZADXjVxsUY1qJuGLGKnLkNNxJNMDhvnKYw+aa3Z4V90fQVyjN1Volgw3DdA59o4wrWEy+2xHc6j2ESi8+cM60fWzZU9sp2XkyJoCnV7nmwk7pZkDy3zvAkeOWzrr3OWeR3wIDAQABAoIBACWMcet8R0+L7YuATQ+H7IeRjhV/pQWHXp9541RXem1DlgtM9N5Oynk78z4s90Uavphqlo1/deohgdl2hLmODjh1THPzCqGtHhUcnyzICmwiA58JgdHVt7e9/eiz8uY6HxGQ01dyr3D4RwSyzyTNItYXSayqRwU0+phgykA8LhFCAQM/UkRXDf6UCFKBhDyE7VPBaDv0xyxNb7dKtE7C6Qo5t5D40xCfQ8ni8OcD5RvshQq5xOWcw7igxAhlmXCu1fuO2CDiSiqXLMENs4NlwilQ3caMXAIzUiblaKwCrrK2noBoitx6vuOR2tKmIZSlTyDAG4vLQQtOHk53hBoupGECgYEAx4jSmLM9uUzNwNY1zfs8iNswxbU3YibNe2Q+IFmOQofvTaq1jBBxdPWX5ifIbuTvOAA33pmJRh+BtWzOBBQC7Z4i9mdfvyWB6s8t9nnTnWIY5Hj+hV5gaqae59MjdudsORR887fxzPIeAwwaETfKaZnYpC6zLaE3BXwhIcjlFTcCgYEAuhcKf16JkEYNIwanVHpUXjFxwAThAogHWZAngRokmai67Iulx+rSUhhtOIXtmjj/EaObsrqo5yCKAVZ5EbPTOajdd9RtFzH6q3bRjRdp8o8ZVx4c1vMNaOnLbvK4YzJlKSZN9N7m255Mg+/ea3veKVZsSVHDMnuYmH8GjncjPJkCgYAOIUlQmPjZA3BapJDA2nbJ9kO47IFUiQzqHQotPkpNudSfemRK2+s87htoqA6Qk9PA8nsCX3sSJS8JSwA317bxXs55Bo8IOT6/AxbtKmlq7sR2gX78sNdBFjWQkyoixHasgB/tHmyYJ9kqPBQoffvuiH+H+OqlY5JC6CxseQ6H9wKBgF69Hj4MDjLiRwve9k9+2/b8azHcCgX05PEG/+WtPpbwHQIScnseJKdhAjH1lSqf+9OqHLlYaGcK3Nejg42spEvFmcLI5iUZ78lde3++PNUdX0RH81zHbrtL06MPdSojXPcfJi8VUCjdJY1CEFVeQZOACS8mrh7EZ8KzYM4k/055AoGAYqjBv3WS8ul7kAsjpZKpIw1QZZaTjBSmLpjB6X8InF+Zihjgm80Dd4RMFnMnEawhFBvnpklvyw5Ce6NSwcC137kN3NVpJypykkXuYkimg7OxgJjR7YFdbQWJWlc+1eB81WTHcEOHVI/DmeV2yVJcv6kA2iC+3/JA0VoJxvrRBKc=" + ], + "keyUse": ["ENC"], + "certificate": [ + "MIICnTCCAYUCBgGQBsyq0jANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdteXJlYWxtMB4XDTI0MDYxMTEwMTQ1NFoXDTM0MDYxMTEwMTYzNFowEjEQMA4GA1UEAwwHbXlyZWFsbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJELXnx8vNnvHeXVVjdNC5xiOWJgTkyodLYKmslUY6ix+rtzX4CVVSHtLRZ8Wu1pGv8kh659SkrFudnM2eLj1y1KOLFwZEMcshV3mYljfB/vVolEPtIXn2ReHrOWT4WNlaXjCvVwJ6YQt+tixS1cnF7pZKSXEPuA4yPfU6OCy8iZo0L7FWie2pqFSecXetnlL/g+39vo8Lmk8IxhN/jFmQA141cbFGNaibhixipy5DTcSTTA4b5ymMPmmt2eFfdH0FcozdVaJYMNw3QOfaOMK1hMvtsR3Oo9hEovPnDOtH1s2VPbKdl5MiaAp1e55sJO6WZA8t87wJHjls669zlnkd8CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAD9wQ+CJ0FRgls3JrUzxwHLgrJ3Yo4+mDFpSe1rh2XYK5FEIWDWSqxaXI3p0cOZq75RZmI2xV8oaiJMUz9WMZkbNe/KtGRzHY1N9AZooicGIsnFu1t++b8taFxxpvKWZgnbOum2PZlfcNiXL0QeMv0wwhfn9zKA9W1DRcqYGbIamoyVlumvbNyIjqXJKwGYIOW6GNt7v3wJl5AJw8qAU/O/DQwWwmzcnFGNRxRxAwI7we8EiQ5JlG0Wi+nyAQn74o3RhNr3zsY0ndmFx9bFV4BBo2AiYGozCDOCCG5HvrmoDbrm//wmGRv0tCwueBzWHL2mhtbZ6sGWmMWfiTJ2HPpg==" + ], + "priority": ["100"], + "algorithm": ["RSA-OAEP"] + } + }, + { + "id": "d85dae25-3728-46a0-980b-46171ba50cdd", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "kid": ["1c1d0c8a-6f0b-48a9-a66f-488489137d85"], + "secret": ["N4wzheVYYBWxFn9VGWTPQQ"], + "priority": ["100"] + } + }, + { + "id": "8c3bb039-6f5b-4bdc-9faa-e0f6038d9e6b", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "kid": ["ce43821c-6cfd-4ea9-a29a-a724a37e6955"], + "secret": [ + "j_8WeQHYt5R6coay0IOUeu9hGvCoJsgnENSoYm0gDlDx6IHOg-f6p17QIaesNmgrzXtJDRpYMhSjpTMHOnHCHLxwUM4eVg9TcszffndB850Yj3PHPeCc5aoHcpYzWN9NDZZ02nBYA04nfbkdlLXiGlpS3I3e502e4DX3rFtbFZ0" + ], + "priority": ["100"], + "algorithm": ["HS512"] + } + } + ] + }, + "internationalizationEnabled": true, + "supportedLocales": ["en", "fr", "es"], + "defaultLocale": "en", + "authenticationFlows": [ + { + "id": "0e1abbbe-40e3-4754-9fe2-8a7d1f82354e", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "f279cc4d-ebed-4390-a5d4-0cbb6dd662ae", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "6926f455-0fd0-4ac6-9fc1-333b86c4150f", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "b11840e7-21ec-4200-bf3c-c7853646a908", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "615b4d0e-e71e-4c96-aed3-b03b34b61808", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "36958ec5-62d7-4d51-8b30-7a6709476aec", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "aa4a7ac2-ec63-48ea-a70f-b3f18992b99a", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "dafdfc68-72eb-49b2-a8f4-495ee25fba21", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "6a39b6db-c81e-4de4-92a8-a9e504593f2e", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "6fa840df-bc04-4045-9e33-8901d183b165", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "4aa24ca0-ad09-4f30-806b-4c699724d731", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "0a914ba4-f662-4b85-af64-74738a222b7f", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "9b40f15f-b690-4fe2-9fe8-07e77d965297", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "id": "c8a9848f-8dd8-4e13-b521-0a537d92ec36", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "603957f8-b0a5-4885-aafd-e2757e431954", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "f41632f9-7fad-427d-ae7a-78ac9b1f51d0", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "27a133ca-e05e-4c93-a3b7-ffe14b4e62ec", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "06cd7382-4944-4499-94dc-9908544e291b", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "5f953def-6f7c-430f-a33f-440ec2d2dddd", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "b3dad9a1-5b82-4e91-a250-157a45694e24", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": true, + "defaultAction": true, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": true, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaAuthRequestedUserHint": "login_hint", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false", + "cibaExpiresIn": "120", + "oauth2DeviceCodeLifespan": "600", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "organizationsEnabled": "false" + }, + "keycloakVersion": "25.0.0", + "userManagedAccessAllowed": false, + "organizationsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} From e573aff6ae9462e8e50113405bf78cd10ed26dd6 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 4 Oct 2024 12:17:54 +0200 Subject: [PATCH 25/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ff7fbe2a..fe130725 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.8", + "version": "11.2.9-rc.0", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 4de9e059e97a590c09c7ceb522daf3c167b39c56 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 4 Oct 2024 12:44:03 +0200 Subject: [PATCH 26/82] Aditional context exclusion --- .../keycloakify/generateFtl/kcContextDeclarationTemplate.ftl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl b/src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl index 5cfc2755..4139df7c 100644 --- a/src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl +++ b/src/bin/keycloakify/generateFtl/kcContextDeclarationTemplate.ftl @@ -235,6 +235,9 @@ function decodeHtmlEntities(htmlStr){ "identityFederationEnabled", "userManagedAccessAllowed" ]?seq_contains(key) + ) || ( + ["flowContext", "session", "realm"]?seq_contains(key) && + areSamePath(path, ["social"]) ) > <#-- <#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]> --> From d5519dbb550f024866d4255954e344f4f4eec846 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 4 Oct 2024 12:44:22 +0200 Subject: [PATCH 27/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fe130725..f6a41f99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.9-rc.0", + "version": "11.2.9-rc.1", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 290ad8b5929a2a54681961fd24ae09d890b24aff Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 4 Oct 2024 12:58:31 +0200 Subject: [PATCH 28/82] Update version ranges for Multi-Page account theme --- .../buildJars/getKeycloakVersionRangeForJar.ts | 4 ++-- src/bin/shared/KeycloakVersionRange.ts | 2 +- src/bin/shared/buildContext.ts | 12 +++++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/bin/keycloakify/buildJars/getKeycloakVersionRangeForJar.ts b/src/bin/keycloakify/buildJars/getKeycloakVersionRangeForJar.ts index 6c301223..89c42ef7 100644 --- a/src/bin/keycloakify/buildJars/getKeycloakVersionRangeForJar.ts +++ b/src/bin/keycloakify/buildJars/getKeycloakVersionRangeForJar.ts @@ -52,9 +52,9 @@ export function getKeycloakVersionRangeForJar(params: { case "0.6": switch (keycloakThemeAdditionalInfoExtensionVersion) { case null: - return undefined; + return "26-and-above" as const; case "1.1.5": - return "25-and-above" as const; + return "25" as const; } } assert>(false); diff --git a/src/bin/shared/KeycloakVersionRange.ts b/src/bin/shared/KeycloakVersionRange.ts index 1e118ea1..860f42f0 100644 --- a/src/bin/shared/KeycloakVersionRange.ts +++ b/src/bin/shared/KeycloakVersionRange.ts @@ -5,5 +5,5 @@ export type KeycloakVersionRange = export namespace KeycloakVersionRange { export type WithoutAccountV1Theme = "22-to-25" | "all-other-versions"; - export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25-and-above"; + export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25" | "26-and-above"; } diff --git a/src/bin/shared/buildContext.ts b/src/bin/shared/buildContext.ts index 852bd1fd..5fc09c3a 100644 --- a/src/bin/shared/buildContext.ts +++ b/src/bin/shared/buildContext.ts @@ -277,7 +277,8 @@ export function getBuildContext(params: { "21-and-below": z.union([z.boolean(), z.string()]), "23": z.union([z.boolean(), z.string()]), "24": z.union([z.boolean(), z.string()]), - "25-and-above": z.union([z.boolean(), z.string()]) + "25": z.union([z.boolean(), z.string()]), + "26-and-above": z.union([z.boolean(), z.string()]) }) .optional() }); @@ -767,7 +768,11 @@ export function getBuildContext(params: { return "24" as const; } - return "25-and-above" as const; + if (buildForKeycloakMajorVersionNumber === 25) { + return "25" as const; + } + + return "26-and-above" as const; })(); assert< @@ -855,7 +860,8 @@ export function getBuildContext(params: { "21-and-below", "23", "24", - "25-and-above" + "25", + "26-and-above" ] as const) { assert< Equals< From 22b0b95e544e2fbd300b522b0a235bab1a661d0c Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 4 Oct 2024 12:59:56 +0200 Subject: [PATCH 29/82] Update readme, support keycloak 26 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 78872df3..2c133223 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@

-Keycloakify is fully compatible with Keycloak from version 11 to 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791) +Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791) ## Sponsors From ab43bb73d7c58c8fe295584670abe2ee1d73f4ff Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 4 Oct 2024 13:00:15 +0200 Subject: [PATCH 30/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f6a41f99..9a704e0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.9-rc.1", + "version": "11.2.9", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From dfe2e1562a1e01a472008c970c06d495b174d93d Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 4 Oct 2024 16:56:02 +0200 Subject: [PATCH 31/82] Fix cache issue --- src/bin/keycloakify/buildJars/buildJar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/keycloakify/buildJars/buildJar.ts b/src/bin/keycloakify/buildJars/buildJar.ts index cdbc3f22..4a952343 100644 --- a/src/bin/keycloakify/buildJars/buildJar.ts +++ b/src/bin/keycloakify/buildJars/buildJar.ts @@ -197,7 +197,7 @@ export async function buildJar(params: { await new Promise((resolve, reject) => child_process.exec( - `mvn install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`, + `mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`, { cwd: keycloakifyBuildCacheDirPath }, error => { if (error !== null) { From ca6accc8891daa16a897d236326693fecd694d85 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 4 Oct 2024 16:56:17 +0200 Subject: [PATCH 32/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a704e0e..0564ddf0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.9", + "version": "11.2.10", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 9e41868e0d79a3c7ecc31ce6be2fc411f1c79672 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 5 Oct 2024 20:30:09 +0200 Subject: [PATCH 33/82] Implement custom handler cli hook --- package.json | 1 + src/bin/add-story.ts | 11 +- src/bin/copy-keycloak-resources-to-public.ts | 9 +- src/bin/eject-page.ts | 11 +- .../initialize-account-theme.ts | 9 +- src/bin/initialize-email-theme.ts | 9 +- src/bin/keycloakify/keycloakify.ts | 9 +- src/bin/main.ts | 297 +++++++++++------- src/bin/shared/buildContext.ts | 9 +- src/bin/shared/constants.ts | 5 + src/bin/shared/customHandler.ts | 35 +++ src/bin/shared/customHandler_caller.ts | 47 +++ src/bin/start-keycloak/start-keycloak.ts | 22 +- src/bin/update-kc-gen.ts | 11 +- src/vite-plugin/vite-plugin.ts | 4 +- 15 files changed, 300 insertions(+), 189 deletions(-) create mode 100644 src/bin/shared/customHandler.ts create mode 100644 src/bin/shared/customHandler_caller.ts diff --git a/package.json b/package.json index 0564ddf0..70ca8dae 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "dist/bin/*.index.js", "dist/bin/*.node", "dist/bin/shared/constants.js", + "dist/bin/shared/customHandler.js", "dist/bin/shared/*.d.ts", "dist/bin/shared/*.js.map", "!dist/vite-plugin/", diff --git a/src/bin/add-story.ts b/src/bin/add-story.ts index b3ed5be7..b2be6d65 100644 --- a/src/bin/add-story.ts +++ b/src/bin/add-story.ts @@ -13,16 +13,11 @@ import * as fs from "fs"; import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path"; import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase"; import { assert, Equals } from "tsafe/assert"; -import type { CliCommandOptions } from "./main"; -import { getBuildContext } from "./shared/buildContext"; +import type { BuildContext } from "./shared/buildContext"; import chalk from "chalk"; -export async function command(params: { cliCommandOptions: CliCommandOptions }) { - const { cliCommandOptions } = params; - - const buildContext = getBuildContext({ - cliCommandOptions - }); +export async function command(params: { buildContext: BuildContext }) { + const { buildContext } = params; console.log(chalk.cyan("Theme type:")); diff --git a/src/bin/copy-keycloak-resources-to-public.ts b/src/bin/copy-keycloak-resources-to-public.ts index b245076a..ba944068 100644 --- a/src/bin/copy-keycloak-resources-to-public.ts +++ b/src/bin/copy-keycloak-resources-to-public.ts @@ -1,11 +1,8 @@ import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic"; -import { getBuildContext } from "./shared/buildContext"; -import type { CliCommandOptions } from "./main"; +import type { BuildContext } from "./shared/buildContext"; -export async function command(params: { cliCommandOptions: CliCommandOptions }) { - const { cliCommandOptions } = params; - - const buildContext = getBuildContext({ cliCommandOptions }); +export async function command(params: { buildContext: BuildContext }) { + const { buildContext } = params; copyKeycloakResourcesToPublic({ buildContext diff --git a/src/bin/eject-page.ts b/src/bin/eject-page.ts index 404483ec..8874fd99 100644 --- a/src/bin/eject-page.ts +++ b/src/bin/eject-page.ts @@ -20,16 +20,11 @@ import { } from "path"; import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase"; import { assert, Equals } from "tsafe/assert"; -import type { CliCommandOptions } from "./main"; -import { getBuildContext } from "./shared/buildContext"; +import type { BuildContext } from "./shared/buildContext"; import chalk from "chalk"; -export async function command(params: { cliCommandOptions: CliCommandOptions }) { - const { cliCommandOptions } = params; - - const buildContext = getBuildContext({ - cliCommandOptions - }); +export async function command(params: { buildContext: BuildContext }) { + const { buildContext } = params; console.log(chalk.cyan("Theme type:")); diff --git a/src/bin/initialize-account-theme/initialize-account-theme.ts b/src/bin/initialize-account-theme/initialize-account-theme.ts index e15b95ba..e29b6733 100644 --- a/src/bin/initialize-account-theme/initialize-account-theme.ts +++ b/src/bin/initialize-account-theme/initialize-account-theme.ts @@ -1,5 +1,4 @@ -import { getBuildContext } from "../shared/buildContext"; -import type { CliCommandOptions } from "../main"; +import type { BuildContext } from "../shared/buildContext"; import cliSelect from "cli-select"; import child_process from "child_process"; import chalk from "chalk"; @@ -8,10 +7,8 @@ import * as fs from "fs"; import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig"; import { generateKcGenTs } from "../shared/generateKcGenTs"; -export async function command(params: { cliCommandOptions: CliCommandOptions }) { - const { cliCommandOptions } = params; - - const buildContext = getBuildContext({ cliCommandOptions }); +export async function command(params: { buildContext: BuildContext }) { + const { buildContext } = params; const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account"); diff --git a/src/bin/initialize-email-theme.ts b/src/bin/initialize-email-theme.ts index f2520645..dfa0287c 100644 --- a/src/bin/initialize-email-theme.ts +++ b/src/bin/initialize-email-theme.ts @@ -1,15 +1,12 @@ import { join as pathJoin, relative as pathRelative } from "path"; import { transformCodebase } from "./tools/transformCodebase"; import { promptKeycloakVersion } from "./shared/promptKeycloakVersion"; -import { getBuildContext } from "./shared/buildContext"; +import type { BuildContext } from "./shared/buildContext"; import * as fs from "fs"; -import type { CliCommandOptions } from "./main"; import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive"; -export async function command(params: { cliCommandOptions: CliCommandOptions }) { - const { cliCommandOptions } = params; - - const buildContext = getBuildContext({ cliCommandOptions }); +export async function command(params: { buildContext: BuildContext }) { + const { buildContext } = params; const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email"); diff --git a/src/bin/keycloakify/keycloakify.ts b/src/bin/keycloakify/keycloakify.ts index b39dcad8..0d027103 100644 --- a/src/bin/keycloakify/keycloakify.ts +++ b/src/bin/keycloakify/keycloakify.ts @@ -2,19 +2,16 @@ import { generateResources } from "./generateResources"; import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path"; import * as child_process from "child_process"; import * as fs from "fs"; -import { getBuildContext } from "../shared/buildContext"; +import type { BuildContext } from "../shared/buildContext"; import { VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES } from "../shared/constants"; import { buildJars } from "./buildJars"; -import type { CliCommandOptions } from "../main"; import chalk from "chalk"; import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion"; import * as os from "os"; import { rmSync } from "../tools/fs.rmSync"; -export async function command(params: { cliCommandOptions: CliCommandOptions }) { - const { cliCommandOptions } = params; - - const buildContext = getBuildContext({ cliCommandOptions }); +export async function command(params: { buildContext: BuildContext }) { + const { buildContext } = params; exit_if_maven_not_installed: { let commandOutput: Buffer | undefined = undefined; diff --git a/src/bin/main.ts b/src/bin/main.ts index 9f54242f..7ef2a9b8 100644 --- a/src/bin/main.ts +++ b/src/bin/main.ts @@ -4,8 +4,10 @@ import { termost } from "termost"; import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion"; import * as child_process from "child_process"; import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx"; +import { callHandlerIfAny } from "./shared/customHandler_caller"; +import { getBuildContext } from "./shared/buildContext"; -export type CliCommandOptions = { +type CliCommandOptions = { projectDirPath: string | undefined; }; @@ -69,115 +71,154 @@ program }) .task({ skip, - handler: async cliCommandOptions => { + handler: async ({ projectDirPath }) => { + const buildContext = getBuildContext({ projectDirPath }); + const { command } = await import("./keycloakify"); - await command({ cliCommandOptions }); + await command({ buildContext }); } }); -program - .command<{ - port: number | undefined; - keycloakVersion: string | undefined; - realmJsonFilePath: string | undefined; - }>({ - name: "start-keycloak", - description: - "Spin up a pre configured Docker image of Keycloak to test your theme." - }) - .option({ - key: "port", - name: (() => { - const name = "port"; +{ + const commandName = "start-keycloak"; - optionsKeys.push(name); + program + .command<{ + port: number | undefined; + keycloakVersion: string | undefined; + realmJsonFilePath: string | undefined; + }>({ + name: commandName, + description: + "Spin up a pre configured Docker image of Keycloak to test your theme." + }) + .option({ + key: "port", + name: (() => { + const name = "port"; - return name; - })(), - description: ["Keycloak server port.", "Example `--port 8085`"].join(" "), - defaultValue: undefined - }) - .option({ - key: "keycloakVersion", - name: (() => { - const name = "keycloak-version"; + optionsKeys.push(name); - optionsKeys.push(name); + return name; + })(), + description: ["Keycloak server port.", "Example `--port 8085`"].join(" "), + defaultValue: undefined + }) + .option({ + key: "keycloakVersion", + name: (() => { + const name = "keycloak-version"; - return name; - })(), - description: [ - "Use a specific version of Keycloak.", - "Example `--keycloak-version 21.1.1`" - ].join(" "), - defaultValue: undefined - }) - .option({ - key: "realmJsonFilePath", - name: (() => { - const name = "import"; + optionsKeys.push(name); - optionsKeys.push(name); + return name; + })(), + description: [ + "Use a specific version of Keycloak.", + "Example `--keycloak-version 21.1.1`" + ].join(" "), + defaultValue: undefined + }) + .option({ + key: "realmJsonFilePath", + name: (() => { + const name = "import"; - return name; - })(), - defaultValue: undefined, - description: [ - "Import your own realm configuration file", - "Example `--import path/to/myrealm-realm.json`" - ].join(" ") - }) - .task({ - skip, - handler: async cliCommandOptions => { - const { command } = await import("./start-keycloak"); + optionsKeys.push(name); - await command({ cliCommandOptions }); - } - }); + return name; + })(), + defaultValue: undefined, + description: [ + "Import your own realm configuration file", + "Example `--import path/to/myrealm-realm.json`" + ].join(" ") + }) + .task({ + skip, + handler: async ({ + projectDirPath, + keycloakVersion, + port, + realmJsonFilePath + }) => { + const buildContext = getBuildContext({ projectDirPath }); -program - .command({ - name: "eject-page", - description: "Eject a Keycloak page." - }) - .task({ - skip, - handler: async cliCommandOptions => { - const { command } = await import("./eject-page"); + const { command } = await import("./start-keycloak"); - await command({ cliCommandOptions }); - } - }); + await command({ + buildContext, + cliCommandOptions: { keycloakVersion, port, realmJsonFilePath } + }); + } + }); +} -program - .command({ - name: "add-story", - description: "Add *.stories.tsx file for a specific page to in your Storybook." - }) - .task({ - skip, - handler: async cliCommandOptions => { - const { command } = await import("./add-story"); +{ + const commandName = "eject-page"; - await command({ cliCommandOptions }); - } - }); + program + .command({ + name: commandName, + description: "Eject a Keycloak page." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const buildContext = getBuildContext({ projectDirPath }); -program - .command({ - name: "initialize-email-theme", - description: "Initialize an email theme." - }) - .task({ - skip, - handler: async cliCommandOptions => { - const { command } = await import("./initialize-email-theme"); + callHandlerIfAny({ buildContext, commandName }); - await command({ cliCommandOptions }); - } - }); + const { command } = await import("./eject-page"); + + await command({ buildContext }); + } + }); +} + +{ + const commandName = "add-story"; + + program + .command({ + name: commandName, + description: + "Add *.stories.tsx file for a specific page to in your Storybook." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const buildContext = getBuildContext({ projectDirPath }); + + callHandlerIfAny({ buildContext, commandName }); + + const { command } = await import("./add-story"); + + await command({ buildContext }); + } + }); +} + +{ + const comandName = "initialize-login-theme"; + + program + .command({ + name: comandName, + description: "Initialize an email theme." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const buildContext = getBuildContext({ projectDirPath }); + + const { command } = await import("./initialize-email-theme"); + + await command({ buildContext }); + } + }); +} program .command({ @@ -186,42 +227,58 @@ program }) .task({ skip, - handler: async cliCommandOptions => { + handler: async ({ projectDirPath }) => { + const buildContext = getBuildContext({ projectDirPath }); + const { command } = await import("./initialize-account-theme"); - await command({ cliCommandOptions }); + await command({ buildContext }); } }); -program - .command({ - name: "copy-keycloak-resources-to-public", - description: - "(Webpack/Create-React-App only) Copy Keycloak default theme resources to the public directory." - }) - .task({ - skip, - handler: async cliCommandOptions => { - const { command } = await import("./copy-keycloak-resources-to-public"); +{ + const commandName = "copy-keycloak-resources-to-public"; - await command({ cliCommandOptions }); - } - }); + program + .command({ + name: commandName, + description: + "(Webpack/Create-React-App only) Copy Keycloak default theme resources to the public directory." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const buildContext = getBuildContext({ projectDirPath }); -program - .command({ - name: "update-kc-gen", - description: - "(Webpack/Create-React-App only) Create/update the kc.gen.ts file in your project." - }) - .task({ - skip, - handler: async cliCommandOptions => { - const { command } = await import("./update-kc-gen"); + const { command } = await import("./copy-keycloak-resources-to-public"); - await command({ cliCommandOptions }); - } - }); + await command({ buildContext }); + } + }); +} + +{ + const commandName = "update-kc-gen"; + + program + .command({ + name: commandName, + description: + "(Webpack/Create-React-App only) Create/update the kc.gen.ts file in your project." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const buildContext = getBuildContext({ projectDirPath }); + + callHandlerIfAny({ buildContext, commandName }); + + const { command } = await import("./update-kc-gen"); + + await command({ buildContext }); + } + }); +} // Fallback to build command if no command is provided { diff --git a/src/bin/shared/buildContext.ts b/src/bin/shared/buildContext.ts index 5fc09c3a..fa658097 100644 --- a/src/bin/shared/buildContext.ts +++ b/src/bin/shared/buildContext.ts @@ -7,7 +7,6 @@ import { dirname as pathDirname } from "path"; import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath"; -import type { CliCommandOptions } from "../main"; import { z } from "zod"; import * as fs from "fs"; import { assert, type Equals } from "tsafe/assert"; @@ -129,14 +128,12 @@ export type ResolvedViteConfig = { }; export function getBuildContext(params: { - cliCommandOptions: CliCommandOptions; + projectDirPath: string | undefined; }): BuildContext { - const { cliCommandOptions } = params; - const projectDirPath = - cliCommandOptions.projectDirPath !== undefined + params.projectDirPath !== undefined ? getAbsoluteAndInOsFormatPath({ - pathIsh: cliCommandOptions.projectDirPath, + pathIsh: params.projectDirPath, cwd: process.cwd() }) : process.cwd(); diff --git a/src/bin/shared/constants.ts b/src/bin/shared/constants.ts index d0707a90..d2137b14 100644 --- a/src/bin/shared/constants.ts +++ b/src/bin/shared/constants.ts @@ -71,3 +71,8 @@ export type AccountThemePageId = (typeof ACCOUNT_THEME_PAGE_IDS)[number]; export const CONTAINER_NAME = "keycloak-keycloakify"; export const FALLBACK_LANGUAGE_TAG = "en"; + +export const CUSTOM_HANDLER_ENV_NAMES = { + COMMAND_NAME: "KEYCLOAKIFY_COMMAND_NAME", + BUILD_CONTEXT: "KEYCLOAKIFY_BUILD_CONTEXT" +}; diff --git a/src/bin/shared/customHandler.ts b/src/bin/shared/customHandler.ts new file mode 100644 index 00000000..d858aaa5 --- /dev/null +++ b/src/bin/shared/customHandler.ts @@ -0,0 +1,35 @@ +import { assert } from "tsafe/assert"; +import type { BuildContext } from "./buildContext"; +import { CUSTOM_HANDLER_ENV_NAMES } from "./constants"; + +export const BIN_NAME = "_keycloakify-custom-handler"; + +export const NOT_IMPLEMENTED_EXIT_CODE = 78; + +export type CommandName = "update-kc-gen" | "eject-page" | "add-story"; + +export type ApiVersion = "v1"; + +export function readParams(params: { apiVersion: ApiVersion }) { + const { apiVersion } = params; + + assert(apiVersion === "v1"); + + const commandName = (() => { + const envValue = process.env[CUSTOM_HANDLER_ENV_NAMES.COMMAND_NAME]; + + assert(envValue !== undefined); + + return envValue as CommandName; + })(); + + const buildContext = (() => { + const envValue = process.env[CUSTOM_HANDLER_ENV_NAMES.BUILD_CONTEXT]; + + assert(envValue !== undefined); + + return JSON.parse(envValue) as BuildContext; + })(); + + return { commandName, buildContext }; +} diff --git a/src/bin/shared/customHandler_caller.ts b/src/bin/shared/customHandler_caller.ts new file mode 100644 index 00000000..38f25972 --- /dev/null +++ b/src/bin/shared/customHandler_caller.ts @@ -0,0 +1,47 @@ +import { assert, type Equals } from "tsafe/assert"; +import type { BuildContext } from "./buildContext"; +import { CUSTOM_HANDLER_ENV_NAMES } from "./constants"; +import { + NOT_IMPLEMENTED_EXIT_CODE, + type CommandName, + BIN_NAME, + ApiVersion +} from "./customHandler"; +import * as child_process from "child_process"; +import { is } from "tsafe/is"; +import { dirname as pathDirname } from "path"; +import * as fs from "fs"; + +assert>(); + +export function callHandlerIfAny(params: { + commandName: CommandName; + buildContext: BuildContext; +}) { + const { commandName, buildContext } = params; + + if (!fs.readdirSync(pathDirname(process.argv[1])).includes(BIN_NAME)) { + return; + } + + try { + child_process.execSync(`npx ${BIN_NAME}`, { + stdio: "inherit", + env: { + ...process.env, + [CUSTOM_HANDLER_ENV_NAMES.COMMAND_NAME]: commandName, + [CUSTOM_HANDLER_ENV_NAMES.BUILD_CONTEXT]: JSON.stringify(buildContext) + } + }); + } catch (error) { + assert(is(error)); + + if (error.code === NOT_IMPLEMENTED_EXIT_CODE) { + return; + } + + process.exit(error.code); + } + + process.exit(0); +} diff --git a/src/bin/start-keycloak/start-keycloak.ts b/src/bin/start-keycloak/start-keycloak.ts index c85b09dc..e243fe2b 100644 --- a/src/bin/start-keycloak/start-keycloak.ts +++ b/src/bin/start-keycloak/start-keycloak.ts @@ -1,6 +1,5 @@ -import { getBuildContext } from "../shared/buildContext"; +import type { BuildContext } from "../shared/buildContext"; import { exclude } from "tsafe/exclude"; -import type { CliCommandOptions as CliCommandOptions_common } from "../main"; import { promptKeycloakVersion } from "../shared/promptKeycloakVersion"; import { CONTAINER_NAME } from "../shared/constants"; import { SemVer } from "../tools/SemVer"; @@ -29,13 +28,14 @@ import { existsAsync } from "../tools/fs.existsAsync"; import { rm } from "../tools/fs.rm"; import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive"; -export type CliCommandOptions = CliCommandOptions_common & { - port: number | undefined; - keycloakVersion: string | undefined; - realmJsonFilePath: string | undefined; -}; - -export async function command(params: { cliCommandOptions: CliCommandOptions }) { +export async function command(params: { + buildContext: BuildContext; + cliCommandOptions: { + port: number | undefined; + keycloakVersion: string | undefined; + realmJsonFilePath: string | undefined; + }; +}) { exit_if_docker_not_installed: { let commandOutput: string | undefined = undefined; @@ -88,9 +88,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions }) process.exit(1); } - const { cliCommandOptions } = params; - - const buildContext = getBuildContext({ cliCommandOptions }); + const { cliCommandOptions, buildContext } = params; const { dockerImageTag } = await (async () => { if (cliCommandOptions.keycloakVersion !== undefined) { diff --git a/src/bin/update-kc-gen.ts b/src/bin/update-kc-gen.ts index 585b0f22..b3d9ce15 100644 --- a/src/bin/update-kc-gen.ts +++ b/src/bin/update-kc-gen.ts @@ -1,13 +1,8 @@ -import type { CliCommandOptions } from "./main"; -import { getBuildContext } from "./shared/buildContext"; +import type { BuildContext } from "./shared/buildContext"; import { generateKcGenTs } from "./shared/generateKcGenTs"; -export async function command(params: { cliCommandOptions: CliCommandOptions }) { - const { cliCommandOptions } = params; - - const buildContext = getBuildContext({ - cliCommandOptions - }); +export async function command(params: { buildContext: BuildContext }) { + const { buildContext } = params; await generateKcGenTs({ buildContext }); } diff --git a/src/vite-plugin/vite-plugin.ts b/src/vite-plugin/vite-plugin.ts index f22cb62a..3a7f3d26 100644 --- a/src/vite-plugin/vite-plugin.ts +++ b/src/vite-plugin/vite-plugin.ts @@ -122,9 +122,7 @@ export function keycloakify(params: keycloakify.Params) { } const buildContext = getBuildContext({ - cliCommandOptions: { - projectDirPath - } + projectDirPath }); copyKeycloakResourcesToPublic({ buildContext }), From ffd405c6dbc378ddf2d992d73d9d3a3caaec8959 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 5 Oct 2024 20:31:41 +0200 Subject: [PATCH 34/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 70ca8dae..bebb3ed3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.2.10", + "version": "11.3.0-rc.0", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From fe65ddb5f89dc8e60765653dec1521d32eb44218 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 5 Oct 2024 21:22:00 +0200 Subject: [PATCH 35/82] Fix missing exports --- package.json | 2 +- src/bin/shared/buildContext.ts | 4 ++-- src/bin/tools/fetchProxyOptions.ts | 14 ++++++++------ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index bebb3ed3..9a799b8f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "dist/bin/*.node", "dist/bin/shared/constants.js", "dist/bin/shared/customHandler.js", - "dist/bin/shared/*.d.ts", + "dist/bin/*.d.ts", "dist/bin/shared/*.js.map", "!dist/vite-plugin/", "dist/vite-plugin/index.js", diff --git a/src/bin/shared/buildContext.ts b/src/bin/shared/buildContext.ts index fa658097..26c746b8 100644 --- a/src/bin/shared/buildContext.ts +++ b/src/bin/shared/buildContext.ts @@ -23,7 +23,7 @@ import { objectEntries } from "tsafe/objectEntries"; import { type ThemeType } from "./constants"; import { id } from "tsafe/id"; import chalk from "chalk"; -import { getProxyFetchOptions, type ProxyFetchOptions } from "../tools/fetchProxyOptions"; +import { getProxyFetchOptions, type FetchOptionsLike } from "../tools/fetchProxyOptions"; import { is } from "tsafe/is"; export type BuildContext = { @@ -42,7 +42,7 @@ export type BuildContext = { * In this case the urlPathname will be "/my-app/" */ urlPathname: string | undefined; assetsDirPath: string; - fetchOptions: ProxyFetchOptions; + fetchOptions: FetchOptionsLike; kcContextExclusionsFtlCode: string | undefined; environmentVariables: { name: string; default: string }[]; themeSrcDirPath: string; diff --git a/src/bin/tools/fetchProxyOptions.ts b/src/bin/tools/fetchProxyOptions.ts index e6f8f497..c607feb9 100644 --- a/src/bin/tools/fetchProxyOptions.ts +++ b/src/bin/tools/fetchProxyOptions.ts @@ -1,16 +1,18 @@ -import { type FetchOptions } from "make-fetch-happen"; import * as child_process from "child_process"; import * as fs from "fs"; import { exclude } from "tsafe/exclude"; -export type ProxyFetchOptions = Pick< - FetchOptions, - "proxy" | "noProxy" | "strictSSL" | "cert" | "ca" ->; +export type FetchOptionsLike = { + proxy: string | undefined; + noProxy: string | string[]; + strictSSL: boolean; + cert: string | string[] | undefined; + ca: string[] | undefined; +}; export function getProxyFetchOptions(params: { npmConfigGetCwd: string; -}): ProxyFetchOptions { +}): FetchOptionsLike { const { npmConfigGetCwd } = params; const cfg = (() => { From e92562fd44db2524a496a7972a810c8877c6f8d9 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 5 Oct 2024 21:23:57 +0200 Subject: [PATCH 36/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a799b8f..39c31f79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.0-rc.0", + "version": "11.3.0-rc.1", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From ef6f5a4c23861df297812fe3533770eebd28b353 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 5 Oct 2024 21:39:14 +0200 Subject: [PATCH 37/82] Add other missing declaration files --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 39c31f79..542b7aff 100644 --- a/package.json +++ b/package.json @@ -38,13 +38,14 @@ "dist/", "!dist/tsconfig.tsbuildinfo", "!dist/bin/", + "dist/bin/**/*.d.ts", "dist/bin/main.js", "dist/bin/*.index.js", "dist/bin/*.node", "dist/bin/shared/constants.js", + "dist/bin/shared/constants.js.map", "dist/bin/shared/customHandler.js", - "dist/bin/*.d.ts", - "dist/bin/shared/*.js.map", + "dist/bin/shared/customHandler.js.map", "!dist/vite-plugin/", "dist/vite-plugin/index.js", "dist/vite-plugin/index.d.ts", From 49b064b5f294b2dfb6781f58928f9dfb5711b172 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 5 Oct 2024 21:39:32 +0200 Subject: [PATCH 38/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 542b7aff..a472aa99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.0-rc.1", + "version": "11.3.0-rc.2", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 9e9ffcd586601ba5927d404c5cb3768cf9156c51 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 5 Oct 2024 22:28:36 +0200 Subject: [PATCH 39/82] add debug logs --- src/bin/main.ts | 4 ++++ src/bin/shared/customHandler_caller.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/bin/main.ts b/src/bin/main.ts index 7ef2a9b8..836bda9b 100644 --- a/src/bin/main.ts +++ b/src/bin/main.ts @@ -168,8 +168,12 @@ program handler: async ({ projectDirPath }) => { const buildContext = getBuildContext({ projectDirPath }); + console.log("before callHandlerIfAny"); + callHandlerIfAny({ buildContext, commandName }); + console.log("after callHandlerIfAny"); + const { command } = await import("./eject-page"); await command({ buildContext }); diff --git a/src/bin/shared/customHandler_caller.ts b/src/bin/shared/customHandler_caller.ts index 38f25972..c4a49e7a 100644 --- a/src/bin/shared/customHandler_caller.ts +++ b/src/bin/shared/customHandler_caller.ts @@ -33,7 +33,10 @@ export function callHandlerIfAny(params: { [CUSTOM_HANDLER_ENV_NAMES.BUILD_CONTEXT]: JSON.stringify(buildContext) } }); - } catch (error) { + } catch (error: any) { + console.log(error.message); + console.log(error.status); + assert(is(error)); if (error.code === NOT_IMPLEMENTED_EXIT_CODE) { From 8fc307bd8d6c85737c48b14db72e3e2ad479b4d8 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 5 Oct 2024 22:29:13 +0200 Subject: [PATCH 40/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a472aa99..d6ffdaf8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.0-rc.2", + "version": "11.3.0-rc.3", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From c4ee6cd85c98472b13f7306e02dedfd8199e66a4 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 06:41:51 +0200 Subject: [PATCH 41/82] Fix not handling correctly exit cause --- src/bin/main.ts | 4 ---- src/bin/shared/customHandler_caller.ts | 10 +++------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/bin/main.ts b/src/bin/main.ts index 836bda9b..7ef2a9b8 100644 --- a/src/bin/main.ts +++ b/src/bin/main.ts @@ -168,12 +168,8 @@ program handler: async ({ projectDirPath }) => { const buildContext = getBuildContext({ projectDirPath }); - console.log("before callHandlerIfAny"); - callHandlerIfAny({ buildContext, commandName }); - console.log("after callHandlerIfAny"); - const { command } = await import("./eject-page"); await command({ buildContext }); diff --git a/src/bin/shared/customHandler_caller.ts b/src/bin/shared/customHandler_caller.ts index c4a49e7a..88d5a41c 100644 --- a/src/bin/shared/customHandler_caller.ts +++ b/src/bin/shared/customHandler_caller.ts @@ -8,7 +8,6 @@ import { ApiVersion } from "./customHandler"; import * as child_process from "child_process"; -import { is } from "tsafe/is"; import { dirname as pathDirname } from "path"; import * as fs from "fs"; @@ -34,16 +33,13 @@ export function callHandlerIfAny(params: { } }); } catch (error: any) { - console.log(error.message); - console.log(error.status); + const status = error.status; - assert(is(error)); - - if (error.code === NOT_IMPLEMENTED_EXIT_CODE) { + if (status === NOT_IMPLEMENTED_EXIT_CODE) { return; } - process.exit(error.code); + process.exit(status); } process.exit(0); From fed6af4dfed482e67e5df36a387be526af3ba60c Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 06:42:04 +0200 Subject: [PATCH 42/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d6ffdaf8..eb36617a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.0-rc.3", + "version": "11.3.0-rc.4", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 2dfb4eda9d34109114870ac2392d0c2fca5b5185 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 06:44:53 +0200 Subject: [PATCH 43/82] No need to handle non react environement with custom handler support --- src/bin/shared/generateKcGenTs.ts | 104 ++++++++---------------------- 1 file changed, 28 insertions(+), 76 deletions(-) diff --git a/src/bin/shared/generateKcGenTs.ts b/src/bin/shared/generateKcGenTs.ts index 7191a55c..e5bb7e86 100644 --- a/src/bin/shared/generateKcGenTs.ts +++ b/src/bin/shared/generateKcGenTs.ts @@ -1,10 +1,8 @@ -import { assert, type Equals } from "tsafe/assert"; -import { id } from "tsafe/id"; +import { assert } from "tsafe/assert"; import type { BuildContext } from "./buildContext"; import * as fs from "fs/promises"; import { join as pathJoin } from "path"; import { existsAsync } from "../tools/fs.existsAsync"; -import { z } from "zod"; export type BuildContextLike = { projectDirPath: string; @@ -25,45 +23,7 @@ export async function generateKcGenTs(params: { }): Promise { const { buildContext } = params; - const isReactProject: boolean = await (async () => { - const parsedPackageJson = await (async () => { - type ParsedPackageJson = { - dependencies?: Record; - devDependencies?: Record; - }; - - const zParsedPackageJson = (() => { - type TargetType = ParsedPackageJson; - - const zTargetType = z.object({ - dependencies: z.record(z.string()).optional(), - devDependencies: z.record(z.string()).optional() - }); - - assert, TargetType>>(); - - return id>(zTargetType); - })(); - - return zParsedPackageJson.parse( - JSON.parse( - (await fs.readFile(buildContext.packageJsonFilePath)).toString("utf8") - ) - ); - })(); - - return ( - { - ...parsedPackageJson.dependencies, - ...parsedPackageJson.devDependencies - }.react !== undefined - ); - })(); - - const filePath = pathJoin( - buildContext.themeSrcDirPath, - `kc.gen.ts${isReactProject ? "x" : ""}` - ); + const filePath = pathJoin(buildContext.themeSrcDirPath, `kc.gen.tsx`); const currentContent = (await existsAsync(filePath)) ? await fs.readFile(filePath) @@ -84,7 +44,7 @@ export async function generateKcGenTs(params: { ``, `// This file is auto-generated by Keycloakify`, ``, - isReactProject && `import { lazy, Suspense, type ReactNode } from "react";`, + `import { lazy, Suspense, type ReactNode } from "react";`, ``, `export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`, ``, @@ -115,35 +75,31 @@ export async function generateKcGenTs(params: { ` }`, `}`, ``, - ...(!isReactProject - ? [] - : [ - hasLoginTheme && - `export const KcLoginPage = lazy(() => import("./login/KcPage"));`, - hasAccountTheme && - `export const KcAccountPage = lazy(() => import("./account/KcPage"));`, - ``, - `export function KcPage(`, - ` props: {`, - ` kcContext: KcContext;`, - ` fallback?: ReactNode;`, - ` }`, - `) {`, - ` const { kcContext, fallback } = props;`, - ` return (`, - ` `, - ` {(() => {`, - ` switch (kcContext.themeType) {`, - hasLoginTheme && - ` case "login": return ;`, - hasAccountTheme && - ` case "account": return ;`, - ` }`, - ` })()}`, - ` `, - ` );`, - `}` - ]), + hasLoginTheme && + `export const KcLoginPage = lazy(() => import("./login/KcPage"));`, + hasAccountTheme && + `export const KcAccountPage = lazy(() => import("./account/KcPage"));`, + ``, + `export function KcPage(`, + ` props: {`, + ` kcContext: KcContext;`, + ` fallback?: ReactNode;`, + ` }`, + `) {`, + ` const { kcContext, fallback } = props;`, + ` return (`, + ` `, + ` {(() => {`, + ` switch (kcContext.themeType) {`, + hasLoginTheme && + ` case "login": return ;`, + hasAccountTheme && + ` case "account": return ;`, + ` }`, + ` })()}`, + ` `, + ` );`, + `}`, ``, `/* prettier-ignore-end */`, `` @@ -160,10 +116,6 @@ export async function generateKcGenTs(params: { await fs.writeFile(filePath, newContent); delete_legacy_file: { - if (!isReactProject) { - break delete_legacy_file; - } - const legacyFilePath = filePath.replace(/tsx$/, "ts"); if (!(await existsAsync(legacyFilePath))) { From 128b27221ad8be85186b06c3df3be005c267dbf2 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 06:45:06 +0200 Subject: [PATCH 44/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eb36617a..133ddec1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.0-rc.4", + "version": "11.3.0-rc.5", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From bc586eceef4f8002835d63258eaf8403a3bb6ea5 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 09:03:15 +0200 Subject: [PATCH 45/82] Make sure the update-kc-gen command is delegated when building with vite --- .../initialize-account-theme.ts | 4 +- src/bin/main.ts | 293 +++++++----------- ...er_caller.ts => customHandler_delegate.ts} | 2 +- src/bin/shared/generateKcGenTs.ts | 127 -------- src/bin/update-kc-gen.ts | 112 ++++++- src/vite-plugin/vite-plugin.ts | 7 +- stories/login/pages/Register.stories.tsx | 6 +- 7 files changed, 239 insertions(+), 312 deletions(-) rename src/bin/shared/{customHandler_caller.ts => customHandler_delegate.ts} (95%) delete mode 100644 src/bin/shared/generateKcGenTs.ts diff --git a/src/bin/initialize-account-theme/initialize-account-theme.ts b/src/bin/initialize-account-theme/initialize-account-theme.ts index e29b6733..3caed327 100644 --- a/src/bin/initialize-account-theme/initialize-account-theme.ts +++ b/src/bin/initialize-account-theme/initialize-account-theme.ts @@ -5,7 +5,7 @@ import chalk from "chalk"; import { join as pathJoin, relative as pathRelative } from "path"; import * as fs from "fs"; import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig"; -import { generateKcGenTs } from "../shared/generateKcGenTs"; +import { command as updateKcGenCommand } from "../update-kc-gen"; export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; @@ -94,7 +94,7 @@ export async function command(params: { buildContext: BuildContext }) { updateAccountThemeImplementationInConfig({ buildContext, accountThemeType }); - await generateKcGenTs({ + await updateKcGenCommand({ buildContext: { ...buildContext, implementedThemeTypes: { diff --git a/src/bin/main.ts b/src/bin/main.ts index 7ef2a9b8..89b03d7c 100644 --- a/src/bin/main.ts +++ b/src/bin/main.ts @@ -4,7 +4,6 @@ import { termost } from "termost"; import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion"; import * as child_process from "child_process"; import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx"; -import { callHandlerIfAny } from "./shared/customHandler_caller"; import { getBuildContext } from "./shared/buildContext"; type CliCommandOptions = { @@ -72,153 +71,117 @@ program .task({ skip, handler: async ({ projectDirPath }) => { - const buildContext = getBuildContext({ projectDirPath }); - const { command } = await import("./keycloakify"); - await command({ buildContext }); + await command({ buildContext: getBuildContext({ projectDirPath }) }); } }); -{ - const commandName = "start-keycloak"; +program + .command<{ + port: number | undefined; + keycloakVersion: string | undefined; + realmJsonFilePath: string | undefined; + }>({ + name: "start-keycloak", + description: + "Spin up a pre configured Docker image of Keycloak to test your theme." + }) + .option({ + key: "port", + name: (() => { + const name = "port"; - program - .command<{ - port: number | undefined; - keycloakVersion: string | undefined; - realmJsonFilePath: string | undefined; - }>({ - name: commandName, - description: - "Spin up a pre configured Docker image of Keycloak to test your theme." - }) - .option({ - key: "port", - name: (() => { - const name = "port"; + optionsKeys.push(name); - optionsKeys.push(name); + return name; + })(), + description: ["Keycloak server port.", "Example `--port 8085`"].join(" "), + defaultValue: undefined + }) + .option({ + key: "keycloakVersion", + name: (() => { + const name = "keycloak-version"; - return name; - })(), - description: ["Keycloak server port.", "Example `--port 8085`"].join(" "), - defaultValue: undefined - }) - .option({ - key: "keycloakVersion", - name: (() => { - const name = "keycloak-version"; + optionsKeys.push(name); - optionsKeys.push(name); + return name; + })(), + description: [ + "Use a specific version of Keycloak.", + "Example `--keycloak-version 21.1.1`" + ].join(" "), + defaultValue: undefined + }) + .option({ + key: "realmJsonFilePath", + name: (() => { + const name = "import"; - return name; - })(), - description: [ - "Use a specific version of Keycloak.", - "Example `--keycloak-version 21.1.1`" - ].join(" "), - defaultValue: undefined - }) - .option({ - key: "realmJsonFilePath", - name: (() => { - const name = "import"; + optionsKeys.push(name); - optionsKeys.push(name); + return name; + })(), + defaultValue: undefined, + description: [ + "Import your own realm configuration file", + "Example `--import path/to/myrealm-realm.json`" + ].join(" ") + }) + .task({ + skip, + handler: async ({ projectDirPath, keycloakVersion, port, realmJsonFilePath }) => { + const { command } = await import("./start-keycloak"); - return name; - })(), - defaultValue: undefined, - description: [ - "Import your own realm configuration file", - "Example `--import path/to/myrealm-realm.json`" - ].join(" ") - }) - .task({ - skip, - handler: async ({ - projectDirPath, - keycloakVersion, - port, - realmJsonFilePath - }) => { - const buildContext = getBuildContext({ projectDirPath }); + await command({ + buildContext: getBuildContext({ projectDirPath }), + cliCommandOptions: { keycloakVersion, port, realmJsonFilePath } + }); + } + }); - const { command } = await import("./start-keycloak"); +program + .command({ + name: "eject-page", + description: "Eject a Keycloak page." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const { command } = await import("./eject-page"); - await command({ - buildContext, - cliCommandOptions: { keycloakVersion, port, realmJsonFilePath } - }); - } - }); -} + await command({ buildContext: getBuildContext({ projectDirPath }) }); + } + }); -{ - const commandName = "eject-page"; +program + .command({ + name: "add-story", + description: "Add *.stories.tsx file for a specific page to in your Storybook." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const { command } = await import("./add-story"); - program - .command({ - name: commandName, - description: "Eject a Keycloak page." - }) - .task({ - skip, - handler: async ({ projectDirPath }) => { - const buildContext = getBuildContext({ projectDirPath }); + await command({ buildContext: getBuildContext({ projectDirPath }) }); + } + }); - callHandlerIfAny({ buildContext, commandName }); +program + .command({ + name: "initialize-login-theme", + description: "Initialize an email theme." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const { command } = await import("./initialize-email-theme"); - const { command } = await import("./eject-page"); - - await command({ buildContext }); - } - }); -} - -{ - const commandName = "add-story"; - - program - .command({ - name: commandName, - description: - "Add *.stories.tsx file for a specific page to in your Storybook." - }) - .task({ - skip, - handler: async ({ projectDirPath }) => { - const buildContext = getBuildContext({ projectDirPath }); - - callHandlerIfAny({ buildContext, commandName }); - - const { command } = await import("./add-story"); - - await command({ buildContext }); - } - }); -} - -{ - const comandName = "initialize-login-theme"; - - program - .command({ - name: comandName, - description: "Initialize an email theme." - }) - .task({ - skip, - handler: async ({ projectDirPath }) => { - const buildContext = getBuildContext({ projectDirPath }); - - const { command } = await import("./initialize-email-theme"); - - await command({ buildContext }); - } - }); -} + await command({ buildContext: getBuildContext({ projectDirPath }) }); + } + }); program .command({ @@ -228,57 +191,41 @@ program .task({ skip, handler: async ({ projectDirPath }) => { - const buildContext = getBuildContext({ projectDirPath }); - const { command } = await import("./initialize-account-theme"); - await command({ buildContext }); + await command({ buildContext: getBuildContext({ projectDirPath }) }); } }); -{ - const commandName = "copy-keycloak-resources-to-public"; +program + .command({ + name: "copy-keycloak-resources-to-public", + description: + "(Webpack/Create-React-App only) Copy Keycloak default theme resources to the public directory." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const { command } = await import("./copy-keycloak-resources-to-public"); - program - .command({ - name: commandName, - description: - "(Webpack/Create-React-App only) Copy Keycloak default theme resources to the public directory." - }) - .task({ - skip, - handler: async ({ projectDirPath }) => { - const buildContext = getBuildContext({ projectDirPath }); + await command({ buildContext: getBuildContext({ projectDirPath }) }); + } + }); - const { command } = await import("./copy-keycloak-resources-to-public"); +program + .command({ + name: "update-kc-gen", + description: + "(Webpack/Create-React-App only) Create/update the kc.gen.ts file in your project." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const { command } = await import("./update-kc-gen"); - await command({ buildContext }); - } - }); -} - -{ - const commandName = "update-kc-gen"; - - program - .command({ - name: commandName, - description: - "(Webpack/Create-React-App only) Create/update the kc.gen.ts file in your project." - }) - .task({ - skip, - handler: async ({ projectDirPath }) => { - const buildContext = getBuildContext({ projectDirPath }); - - callHandlerIfAny({ buildContext, commandName }); - - const { command } = await import("./update-kc-gen"); - - await command({ buildContext }); - } - }); -} + await command({ buildContext: getBuildContext({ projectDirPath }) }); + } + }); // Fallback to build command if no command is provided { diff --git a/src/bin/shared/customHandler_caller.ts b/src/bin/shared/customHandler_delegate.ts similarity index 95% rename from src/bin/shared/customHandler_caller.ts rename to src/bin/shared/customHandler_delegate.ts index 88d5a41c..a41cbc7b 100644 --- a/src/bin/shared/customHandler_caller.ts +++ b/src/bin/shared/customHandler_delegate.ts @@ -13,7 +13,7 @@ import * as fs from "fs"; assert>(); -export function callHandlerIfAny(params: { +export function maybeDelegateCommandToCustomHandler(params: { commandName: CommandName; buildContext: BuildContext; }) { diff --git a/src/bin/shared/generateKcGenTs.ts b/src/bin/shared/generateKcGenTs.ts deleted file mode 100644 index e5bb7e86..00000000 --- a/src/bin/shared/generateKcGenTs.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { assert } from "tsafe/assert"; -import type { BuildContext } from "./buildContext"; -import * as fs from "fs/promises"; -import { join as pathJoin } from "path"; -import { existsAsync } from "../tools/fs.existsAsync"; - -export type BuildContextLike = { - projectDirPath: string; - themeNames: string[]; - environmentVariables: { name: string; default: string }[]; - themeSrcDirPath: string; - implementedThemeTypes: Pick< - BuildContext["implementedThemeTypes"], - "login" | "account" - >; - packageJsonFilePath: string; -}; - -assert(); - -export async function generateKcGenTs(params: { - buildContext: BuildContextLike; -}): Promise { - const { buildContext } = params; - - const filePath = pathJoin(buildContext.themeSrcDirPath, `kc.gen.tsx`); - - const currentContent = (await existsAsync(filePath)) - ? await fs.readFile(filePath) - : undefined; - - const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented; - const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented; - - const newContent = Buffer.from( - [ - `/* prettier-ignore-start */`, - ``, - `/* eslint-disable */`, - ``, - `// @ts-nocheck`, - ``, - `// noinspection JSUnusedGlobalSymbols`, - ``, - `// This file is auto-generated by Keycloakify`, - ``, - `import { lazy, Suspense, type ReactNode } from "react";`, - ``, - `export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`, - ``, - `export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`, - ``, - `export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`, - ``, - `export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`, - ``, - `export const kcEnvDefaults: Record = ${JSON.stringify( - Object.fromEntries( - buildContext.environmentVariables.map( - ({ name, default: defaultValue }) => [name, defaultValue] - ) - ), - null, - 2 - )};`, - ``, - `export type KcContext =`, - hasLoginTheme && ` | import("./login/KcContext").KcContext`, - hasAccountTheme && ` | import("./account/KcContext").KcContext`, - ` ;`, - ``, - `declare global {`, - ` interface Window {`, - ` kcContext?: KcContext;`, - ` }`, - `}`, - ``, - hasLoginTheme && - `export const KcLoginPage = lazy(() => import("./login/KcPage"));`, - hasAccountTheme && - `export const KcAccountPage = lazy(() => import("./account/KcPage"));`, - ``, - `export function KcPage(`, - ` props: {`, - ` kcContext: KcContext;`, - ` fallback?: ReactNode;`, - ` }`, - `) {`, - ` const { kcContext, fallback } = props;`, - ` return (`, - ` `, - ` {(() => {`, - ` switch (kcContext.themeType) {`, - hasLoginTheme && - ` case "login": return ;`, - hasAccountTheme && - ` case "account": return ;`, - ` }`, - ` })()}`, - ` `, - ` );`, - `}`, - ``, - `/* prettier-ignore-end */`, - `` - ] - .filter(item => typeof item === "string") - .join("\n"), - "utf8" - ); - - if (currentContent !== undefined && currentContent.equals(newContent)) { - return; - } - - await fs.writeFile(filePath, newContent); - - delete_legacy_file: { - const legacyFilePath = filePath.replace(/tsx$/, "ts"); - - if (!(await existsAsync(legacyFilePath))) { - break delete_legacy_file; - } - - await fs.unlink(legacyFilePath); - } -} diff --git a/src/bin/update-kc-gen.ts b/src/bin/update-kc-gen.ts index b3d9ce15..f126bcb1 100644 --- a/src/bin/update-kc-gen.ts +++ b/src/bin/update-kc-gen.ts @@ -1,8 +1,116 @@ import type { BuildContext } from "./shared/buildContext"; -import { generateKcGenTs } from "./shared/generateKcGenTs"; +import * as fs from "fs/promises"; +import { join as pathJoin } from "path"; +import { existsAsync } from "./tools/fs.existsAsync"; +import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate"; export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; - await generateKcGenTs({ buildContext }); + maybeDelegateCommandToCustomHandler({ + commandName: "update-kc-gen", + buildContext + }); + + const filePath = pathJoin(buildContext.themeSrcDirPath, `kc.gen.tsx`); + + const currentContent = (await existsAsync(filePath)) + ? await fs.readFile(filePath) + : undefined; + + const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented; + const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented; + + const newContent = Buffer.from( + [ + `/* prettier-ignore-start */`, + ``, + `/* eslint-disable */`, + ``, + `// @ts-nocheck`, + ``, + `// noinspection JSUnusedGlobalSymbols`, + ``, + `// This file is auto-generated by Keycloakify`, + ``, + `import { lazy, Suspense, type ReactNode } from "react";`, + ``, + `export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`, + ``, + `export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`, + ``, + `export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`, + ``, + `export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`, + ``, + `export const kcEnvDefaults: Record = ${JSON.stringify( + Object.fromEntries( + buildContext.environmentVariables.map( + ({ name, default: defaultValue }) => [name, defaultValue] + ) + ), + null, + 2 + )};`, + ``, + `export type KcContext =`, + hasLoginTheme && ` | import("./login/KcContext").KcContext`, + hasAccountTheme && ` | import("./account/KcContext").KcContext`, + ` ;`, + ``, + `declare global {`, + ` interface Window {`, + ` kcContext?: KcContext;`, + ` }`, + `}`, + ``, + hasLoginTheme && + `export const KcLoginPage = lazy(() => import("./login/KcPage"));`, + hasAccountTheme && + `export const KcAccountPage = lazy(() => import("./account/KcPage"));`, + ``, + `export function KcPage(`, + ` props: {`, + ` kcContext: KcContext;`, + ` fallback?: ReactNode;`, + ` }`, + `) {`, + ` const { kcContext, fallback } = props;`, + ` return (`, + ` `, + ` {(() => {`, + ` switch (kcContext.themeType) {`, + hasLoginTheme && + ` case "login": return ;`, + hasAccountTheme && + ` case "account": return ;`, + ` }`, + ` })()}`, + ` `, + ` );`, + `}`, + ``, + `/* prettier-ignore-end */`, + `` + ] + .filter(item => typeof item === "string") + .join("\n"), + "utf8" + ); + + if (currentContent !== undefined && currentContent.equals(newContent)) { + return; + } + + await fs.writeFile(filePath, newContent); + + delete_legacy_file: { + const legacyFilePath = filePath.replace(/tsx$/, "ts"); + + if (!(await existsAsync(legacyFilePath))) { + break delete_legacy_file; + } + + await fs.unlink(legacyFilePath); + } } diff --git a/src/vite-plugin/vite-plugin.ts b/src/vite-plugin/vite-plugin.ts index 3a7f3d26..e6dfb054 100644 --- a/src/vite-plugin/vite-plugin.ts +++ b/src/vite-plugin/vite-plugin.ts @@ -15,7 +15,7 @@ import { type ResolvedViteConfig } from "../bin/shared/buildContext"; import MagicString from "magic-string"; -import { generateKcGenTs } from "../bin/shared/generateKcGenTs"; +import { command as updateKcGenCommand } from "../bin/update-kc-gen"; export namespace keycloakify { export type Params = BuildOptions & { @@ -125,8 +125,9 @@ export function keycloakify(params: keycloakify.Params) { projectDirPath }); - copyKeycloakResourcesToPublic({ buildContext }), - await generateKcGenTs({ buildContext }); + copyKeycloakResourcesToPublic({ buildContext }); + + await updateKcGenCommand({ buildContext }); }, transform: (code, id) => { assert(command !== undefined); diff --git a/stories/login/pages/Register.stories.tsx b/stories/login/pages/Register.stories.tsx index f25568cc..09cdf249 100644 --- a/stories/login/pages/Register.stories.tsx +++ b/stories/login/pages/Register.stories.tsx @@ -115,7 +115,6 @@ export const WithFavoritePet: Story = { ) }; - export const WithNewsletter: Story = { render: () => ( ) }; - export const WithEmailAsUsername: Story = { render: () => ( Date: Sun, 6 Oct 2024 09:07:10 +0200 Subject: [PATCH 46/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 133ddec1..791e13e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.0-rc.5", + "version": "11.3.0-rc.6", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 182fb430f1151e9b988c16f312682ee60d14da6e Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 12:44:46 +0200 Subject: [PATCH 47/82] Fix dead code --- .../updateAccountThemeImplementationInConfig.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bin/initialize-account-theme/updateAccountThemeImplementationInConfig.ts b/src/bin/initialize-account-theme/updateAccountThemeImplementationInConfig.ts index 976beb9a..1c60ef03 100644 --- a/src/bin/initialize-account-theme/updateAccountThemeImplementationInConfig.ts +++ b/src/bin/initialize-account-theme/updateAccountThemeImplementationInConfig.ts @@ -8,12 +8,14 @@ import { id } from "tsafe/id"; export type BuildContextLike = { bundler: BuildContext["bundler"]; + projectDirPath: string; + packageJsonFilePath: string; }; assert(); export function updateAccountThemeImplementationInConfig(params: { - buildContext: BuildContext; + buildContext: BuildContextLike; accountThemeType: "Single-Page" | "Multi-Page"; }) { const { buildContext, accountThemeType } = params; From 9910762abc4069a85446549bdc66128a51646940 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 13:18:12 +0200 Subject: [PATCH 48/82] Add initialize-email-theme, initialize-account-theme and copy-keycloak-resources-to-public to commands that can be delegated to a custom handler --- src/bin/copy-keycloak-resources-to-public.ts | 6 ++++++ .../initialize-account-theme/initialize-account-theme.ts | 6 ++++++ src/bin/initialize-email-theme.ts | 6 ++++++ src/bin/shared/customHandler.ts | 8 +++++++- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/bin/copy-keycloak-resources-to-public.ts b/src/bin/copy-keycloak-resources-to-public.ts index ba944068..b48f9d0e 100644 --- a/src/bin/copy-keycloak-resources-to-public.ts +++ b/src/bin/copy-keycloak-resources-to-public.ts @@ -1,9 +1,15 @@ import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic"; import type { BuildContext } from "./shared/buildContext"; +import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate"; export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; + maybeDelegateCommandToCustomHandler({ + commandName: "copy-keycloak-resources-to-public", + buildContext + }); + copyKeycloakResourcesToPublic({ buildContext }); diff --git a/src/bin/initialize-account-theme/initialize-account-theme.ts b/src/bin/initialize-account-theme/initialize-account-theme.ts index 3caed327..e7ae9223 100644 --- a/src/bin/initialize-account-theme/initialize-account-theme.ts +++ b/src/bin/initialize-account-theme/initialize-account-theme.ts @@ -6,10 +6,16 @@ import { join as pathJoin, relative as pathRelative } from "path"; import * as fs from "fs"; import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig"; import { command as updateKcGenCommand } from "../update-kc-gen"; +import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate"; export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; + maybeDelegateCommandToCustomHandler({ + commandName: "initialize-account-theme", + buildContext + }); + const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account"); if ( diff --git a/src/bin/initialize-email-theme.ts b/src/bin/initialize-email-theme.ts index dfa0287c..d5ce0fd8 100644 --- a/src/bin/initialize-email-theme.ts +++ b/src/bin/initialize-email-theme.ts @@ -4,10 +4,16 @@ import { promptKeycloakVersion } from "./shared/promptKeycloakVersion"; import type { BuildContext } from "./shared/buildContext"; import * as fs from "fs"; import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive"; +import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate"; export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; + maybeDelegateCommandToCustomHandler({ + commandName: "initialize-email-theme", + buildContext + }); + const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email"); if ( diff --git a/src/bin/shared/customHandler.ts b/src/bin/shared/customHandler.ts index d858aaa5..7c0b9e1c 100644 --- a/src/bin/shared/customHandler.ts +++ b/src/bin/shared/customHandler.ts @@ -6,7 +6,13 @@ export const BIN_NAME = "_keycloakify-custom-handler"; export const NOT_IMPLEMENTED_EXIT_CODE = 78; -export type CommandName = "update-kc-gen" | "eject-page" | "add-story"; +export type CommandName = + | "update-kc-gen" + | "eject-page" + | "add-story" + | "initialize-account-theme" + | "initialize-email-theme" + | "copy-keycloak-resources-to-public"; export type ApiVersion = "v1"; From 6aa60e685bac743686c1fb5a7d350e4415942f3a Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 13:19:12 +0200 Subject: [PATCH 49/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 791e13e6..ae774e39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.0-rc.6", + "version": "11.3.0-rc.7", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From d626699f08364c933e8fd13bbdb008aa0417a698 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 15:09:53 +0200 Subject: [PATCH 50/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ae774e39..0fec7284 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.0-rc.7", + "version": "11.3.0", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 9a6a71c8bc3ffc422ca7091d6f6621275de380e9 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 15:37:32 +0200 Subject: [PATCH 51/82] Fix litle inconsistency --- src/bin/eject-page.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bin/eject-page.ts b/src/bin/eject-page.ts index 8874fd99..bacafe6f 100644 --- a/src/bin/eject-page.ts +++ b/src/bin/eject-page.ts @@ -239,12 +239,12 @@ export async function command(params: { buildContext: BuildContext }) { )} copy pasted from the Keycloakify source code into your project` ); - edit_KcApp: { + edit_KcPage: { if ( pageIdOrComponent !== templateValue && pageIdOrComponent !== userProfileFormFieldsValue ) { - break edit_KcApp; + break edit_KcPage; } const kcAppTsxPath = pathJoin( From 16906297174161d4e2fc7c3fb71324442f2dbe9b Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 22:08:43 +0200 Subject: [PATCH 52/82] Fix: check for delegation of the eject-page command --- src/bin/eject-page.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/bin/eject-page.ts b/src/bin/eject-page.ts index bacafe6f..ee4a3581 100644 --- a/src/bin/eject-page.ts +++ b/src/bin/eject-page.ts @@ -22,10 +22,16 @@ import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase"; import { assert, Equals } from "tsafe/assert"; import type { BuildContext } from "./shared/buildContext"; import chalk from "chalk"; +import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate"; export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; + maybeDelegateCommandToCustomHandler({ + commandName: "eject-page", + buildContext + }); + console.log(chalk.cyan("Theme type:")); const themeType = await (async () => { From a40810b3641fab4e5cd775c0d407bfaebf8bf4bb Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 22:09:21 +0200 Subject: [PATCH 53/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0fec7284..0ac79b18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.0", + "version": "11.3.1", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From dc4f386e7afce2993cdf05331c71e14e17c6dca7 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 22:55:18 +0200 Subject: [PATCH 54/82] Fix vite quitting if custom handler implemented --- src/bin/copy-keycloak-resources-to-public.ts | 90 +++++++++++++++++- src/bin/eject-page.ts | 6 +- .../initialize-account-theme.ts | 6 +- src/bin/initialize-email-theme.ts | 6 +- .../shared/copyKeycloakResourcesToPublic.ts | 95 ------------------- src/bin/shared/customHandler_delegate.ts | 8 +- src/bin/update-kc-gen.ts | 6 +- src/vite-plugin/vite-plugin.ts | 4 +- 8 files changed, 111 insertions(+), 110 deletions(-) delete mode 100644 src/bin/shared/copyKeycloakResourcesToPublic.ts diff --git a/src/bin/copy-keycloak-resources-to-public.ts b/src/bin/copy-keycloak-resources-to-public.ts index b48f9d0e..1925860d 100644 --- a/src/bin/copy-keycloak-resources-to-public.ts +++ b/src/bin/copy-keycloak-resources-to-public.ts @@ -1,16 +1,96 @@ -import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic"; -import type { BuildContext } from "./shared/buildContext"; import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate"; +import { join as pathJoin, dirname as pathDirname } from "path"; +import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "./shared/constants"; +import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion"; +import * as fs from "fs"; +import { rmSync } from "./tools/fs.rmSync"; +import type { BuildContext } from "./shared/buildContext"; +import { transformCodebase } from "./tools/transformCodebase"; +import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath"; export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; - maybeDelegateCommandToCustomHandler({ + const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ commandName: "copy-keycloak-resources-to-public", buildContext }); - copyKeycloakResourcesToPublic({ - buildContext + if (hasBeenHandled) { + return; + } + + const destDirPath = pathJoin( + buildContext.publicDirPath, + WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES + ); + + const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo"); + + const keycloakifyBuildinfoRaw = JSON.stringify( + { + keycloakifyVersion: readThisNpmPackageVersion() + }, + null, + 2 + ); + + skip_if_already_done: { + if (!fs.existsSync(keycloakifyBuildinfoFilePath)) { + break skip_if_already_done; + } + + const keycloakifyBuildinfoRaw_previousRun = fs + .readFileSync(keycloakifyBuildinfoFilePath) + .toString("utf8"); + + if (keycloakifyBuildinfoRaw_previousRun !== keycloakifyBuildinfoRaw) { + break skip_if_already_done; + } + + return; + } + + rmSync(destDirPath, { force: true, recursive: true }); + + // NOTE: To remove in a while, remove the legacy keycloak-resources directory + rmSync(pathJoin(pathDirname(destDirPath), "keycloak-resources"), { + force: true, + recursive: true }); + rmSync(pathJoin(pathDirname(destDirPath), ".keycloakify"), { + force: true, + recursive: true + }); + + fs.mkdirSync(destDirPath, { recursive: true }); + + fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8")); + + transformCodebase({ + srcDirPath: pathJoin( + getThisCodebaseRootDirPath(), + "res", + "public", + WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES + ), + destDirPath + }); + + fs.writeFileSync( + pathJoin(destDirPath, "README.txt"), + Buffer.from( + // prettier-ignore + [ + "This directory is only used in dev mode by Keycloakify", + "It won't be included in your final build.", + "Do not modify anything in this directory.", + ].join("\n") + ) + ); + + fs.writeFileSync( + keycloakifyBuildinfoFilePath, + Buffer.from(keycloakifyBuildinfoRaw, "utf8") + ); } diff --git a/src/bin/eject-page.ts b/src/bin/eject-page.ts index ee4a3581..e982c2f7 100644 --- a/src/bin/eject-page.ts +++ b/src/bin/eject-page.ts @@ -27,11 +27,15 @@ import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_dele export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; - maybeDelegateCommandToCustomHandler({ + const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ commandName: "eject-page", buildContext }); + if (hasBeenHandled) { + return; + } + console.log(chalk.cyan("Theme type:")); const themeType = await (async () => { diff --git a/src/bin/initialize-account-theme/initialize-account-theme.ts b/src/bin/initialize-account-theme/initialize-account-theme.ts index e7ae9223..dd7c61c8 100644 --- a/src/bin/initialize-account-theme/initialize-account-theme.ts +++ b/src/bin/initialize-account-theme/initialize-account-theme.ts @@ -11,11 +11,15 @@ import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_del export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; - maybeDelegateCommandToCustomHandler({ + const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ commandName: "initialize-account-theme", buildContext }); + if (hasBeenHandled) { + return; + } + const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account"); if ( diff --git a/src/bin/initialize-email-theme.ts b/src/bin/initialize-email-theme.ts index d5ce0fd8..7a81205b 100644 --- a/src/bin/initialize-email-theme.ts +++ b/src/bin/initialize-email-theme.ts @@ -9,11 +9,15 @@ import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_dele export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; - maybeDelegateCommandToCustomHandler({ + const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ commandName: "initialize-email-theme", buildContext }); + if (hasBeenHandled) { + return; + } + const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email"); if ( diff --git a/src/bin/shared/copyKeycloakResourcesToPublic.ts b/src/bin/shared/copyKeycloakResourcesToPublic.ts deleted file mode 100644 index db78d313..00000000 --- a/src/bin/shared/copyKeycloakResourcesToPublic.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { join as pathJoin, dirname as pathDirname } from "path"; -import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../shared/constants"; -import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion"; -import { assert } from "tsafe/assert"; -import * as fs from "fs"; -import { rmSync } from "../tools/fs.rmSync"; -import type { BuildContext } from "./buildContext"; -import { transformCodebase } from "../tools/transformCodebase"; -import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath"; - -export type BuildContextLike = { - publicDirPath: string; -}; - -assert(); - -export function copyKeycloakResourcesToPublic(params: { - buildContext: BuildContextLike; -}) { - const { buildContext } = params; - - const destDirPath = pathJoin( - buildContext.publicDirPath, - WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES - ); - - const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo"); - - const keycloakifyBuildinfoRaw = JSON.stringify( - { - keycloakifyVersion: readThisNpmPackageVersion() - }, - null, - 2 - ); - - skip_if_already_done: { - if (!fs.existsSync(keycloakifyBuildinfoFilePath)) { - break skip_if_already_done; - } - - const keycloakifyBuildinfoRaw_previousRun = fs - .readFileSync(keycloakifyBuildinfoFilePath) - .toString("utf8"); - - if (keycloakifyBuildinfoRaw_previousRun !== keycloakifyBuildinfoRaw) { - break skip_if_already_done; - } - - return; - } - - rmSync(destDirPath, { force: true, recursive: true }); - - // NOTE: To remove in a while, remove the legacy keycloak-resources directory - rmSync(pathJoin(pathDirname(destDirPath), "keycloak-resources"), { - force: true, - recursive: true - }); - rmSync(pathJoin(pathDirname(destDirPath), ".keycloakify"), { - force: true, - recursive: true - }); - - fs.mkdirSync(destDirPath, { recursive: true }); - - fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8")); - - transformCodebase({ - srcDirPath: pathJoin( - getThisCodebaseRootDirPath(), - "res", - "public", - WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES - ), - destDirPath - }); - - fs.writeFileSync( - pathJoin(destDirPath, "README.txt"), - Buffer.from( - // prettier-ignore - [ - "This directory is only used in dev mode by Keycloakify", - "It won't be included in your final build.", - "Do not modify anything in this directory.", - ].join("\n") - ) - ); - - fs.writeFileSync( - keycloakifyBuildinfoFilePath, - Buffer.from(keycloakifyBuildinfoRaw, "utf8") - ); -} diff --git a/src/bin/shared/customHandler_delegate.ts b/src/bin/shared/customHandler_delegate.ts index a41cbc7b..691989df 100644 --- a/src/bin/shared/customHandler_delegate.ts +++ b/src/bin/shared/customHandler_delegate.ts @@ -16,11 +16,11 @@ assert>(); export function maybeDelegateCommandToCustomHandler(params: { commandName: CommandName; buildContext: BuildContext; -}) { +}): { hasBeenHandled: boolean } { const { commandName, buildContext } = params; if (!fs.readdirSync(pathDirname(process.argv[1])).includes(BIN_NAME)) { - return; + return { hasBeenHandled: false }; } try { @@ -36,11 +36,11 @@ export function maybeDelegateCommandToCustomHandler(params: { const status = error.status; if (status === NOT_IMPLEMENTED_EXIT_CODE) { - return; + return { hasBeenHandled: false }; } process.exit(status); } - process.exit(0); + return { hasBeenHandled: true }; } diff --git a/src/bin/update-kc-gen.ts b/src/bin/update-kc-gen.ts index f126bcb1..e1366fd4 100644 --- a/src/bin/update-kc-gen.ts +++ b/src/bin/update-kc-gen.ts @@ -7,11 +7,15 @@ import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_dele export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; - maybeDelegateCommandToCustomHandler({ + const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ commandName: "update-kc-gen", buildContext }); + if (hasBeenHandled) { + return; + } + const filePath = pathJoin(buildContext.themeSrcDirPath, `kc.gen.tsx`); const currentContent = (await existsAsync(filePath)) diff --git a/src/vite-plugin/vite-plugin.ts b/src/vite-plugin/vite-plugin.ts index e6dfb054..d7ec06d5 100644 --- a/src/vite-plugin/vite-plugin.ts +++ b/src/vite-plugin/vite-plugin.ts @@ -6,7 +6,7 @@ import { } from "../bin/shared/constants"; import { id } from "tsafe/id"; import { rm } from "../bin/tools/fs.rm"; -import { copyKeycloakResourcesToPublic } from "../bin/shared/copyKeycloakResourcesToPublic"; +import { command as copyKeycloakResourcesToPublicCommand } from "../bin/copy-keycloak-resources-to-public"; import { assert } from "tsafe/assert"; import { getBuildContext, @@ -125,7 +125,7 @@ export function keycloakify(params: keycloakify.Params) { projectDirPath }); - copyKeycloakResourcesToPublic({ buildContext }); + await copyKeycloakResourcesToPublicCommand({ buildContext }); await updateKcGenCommand({ buildContext }); }, From c3e821088b4ad15c70d9336fea25550f5ac85b10 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 6 Oct 2024 22:56:42 +0200 Subject: [PATCH 55/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0ac79b18..f2e015ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.1", + "version": "11.3.2", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From b879569b81c804623cee548eb74c71827db609f7 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 7 Oct 2024 20:56:03 +0200 Subject: [PATCH 56/82] Announcement about Keycloak 26 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 2c133223..c6edf2d0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791) +> 📣 **Keycloakify 26 Released** +> Themes built with Keycloakify versions **prior** to Keycloak 26 are **incompatible** with Keycloak 26. +> To ensure compatibility, simply upgrade to the latest Keycloakify version for your major release (v10 or v11) and rebuild your theme. +> No breaking changes have been introduced, but the target version ranges have been updated. For more details, see [this guide](https://docs.keycloakify.dev/targeting-specific-keycloak-versions). + ## Sponsors Friends for the project, we trust and recommend their services. From 987335399008e2ba57288c0cf765f458ca0965d2 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 7 Oct 2024 21:02:51 +0200 Subject: [PATCH 57/82] Fix initialize-email-theme --- src/bin/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/main.ts b/src/bin/main.ts index 89b03d7c..a6abe1f5 100644 --- a/src/bin/main.ts +++ b/src/bin/main.ts @@ -171,7 +171,7 @@ program program .command({ - name: "initialize-login-theme", + name: "initialize-email-theme", description: "Initialize an email theme." }) .task({ From f1cb165bddbbceba77640bca24b7b71f9a87610b Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 7 Oct 2024 21:03:04 +0200 Subject: [PATCH 58/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f2e015ac..cf03259f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.2", + "version": "11.3.3", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From ddeade97756b37ca68b0a8a2e0aefbe718f55b28 Mon Sep 17 00:00:00 2001 From: Nima Shkouhfar Date: Sun, 29 Sep 2024 04:35:02 -0400 Subject: [PATCH 59/82] Changes: - First draft of test coverage improvement for storybooks - code's page html rendering issue fixed --- src/login/pages/Code.tsx | 10 +- stories/login/pages/Code.stories.tsx | 39 ++++++ .../pages/DeleteAccountConfirm.stories.tsx | 30 ++++ .../login/pages/DeleteCredential.stories.tsx | 10 ++ stories/login/pages/Error.stories.tsx | 35 +++++ .../pages/FrontchannelLogout.stories.tsx | 11 ++ .../pages/IdpReviewUserProfile.stories.tsx | 44 ++++++ stories/login/pages/Info.stories.tsx | 39 ++++++ stories/login/pages/Login.stories.tsx | 128 ++++++++++++++++++ .../login/pages/LoginConfigTotp.stories.tsx | 21 +++ .../login/pages/LoginIdpLinkEmail.stories.tsx | 46 +++++++ stories/login/pages/Register.stories.tsx | 62 +++++++++ stories/login/pages/Terms.stories.tsx | 29 ++++ 13 files changed, 503 insertions(+), 1 deletion(-) diff --git a/src/login/pages/Code.tsx b/src/login/pages/Code.tsx index 576a2ccd..4bca62f6 100644 --- a/src/login/pages/Code.tsx +++ b/src/login/pages/Code.tsx @@ -2,6 +2,7 @@ import { getKcClsx } from "keycloakify/login/lib/kcClsx"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; +import { kcSanitize } from "keycloakify/lib/kcSanitize"; export default function Code(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; @@ -30,7 +31,14 @@ export default function Code(props: PageProps ) : ( -

{code.error}

+ code.error && ( +

+ ) )} diff --git a/stories/login/pages/Code.stories.tsx b/stories/login/pages/Code.stories.tsx index 0fcdf87f..b93125bf 100644 --- a/stories/login/pages/Code.stories.tsx +++ b/stories/login/pages/Code.stories.tsx @@ -16,3 +16,42 @@ type Story = StoryObj; export const Default: Story = { render: () => }; +export const WithErrorCode: Story = { + render: () => ( + + ) +}; +export const WithFrenchLanguage: Story = { + render: () => ( + + ) +}; +export const WithHtmlErrorMessage: Story = { + render: () => ( + Try again" + } + }} + /> + ) +}; diff --git a/stories/login/pages/DeleteAccountConfirm.stories.tsx b/stories/login/pages/DeleteAccountConfirm.stories.tsx index 501fa0c4..0d865dfe 100644 --- a/stories/login/pages/DeleteAccountConfirm.stories.tsx +++ b/stories/login/pages/DeleteAccountConfirm.stories.tsx @@ -16,3 +16,33 @@ type Story = StoryObj; export const Default: Story = { render: () => }; +export const WithAIAFlow: Story = { + render: () => ( + + ) +}; +export const WithoutAIAFlow: Story = { + render: () => ( + + ) +}; +export const WithCustomButtonStyle: Story = { + render: () => ( + + ) +}; diff --git a/stories/login/pages/DeleteCredential.stories.tsx b/stories/login/pages/DeleteCredential.stories.tsx index 7b211a52..38619d5b 100644 --- a/stories/login/pages/DeleteCredential.stories.tsx +++ b/stories/login/pages/DeleteCredential.stories.tsx @@ -16,3 +16,13 @@ type Story = StoryObj; export const Default: Story = { render: () => }; +export const WithCustomCredentialLabel: Story = { + render: () => ( + + ) +}; diff --git a/stories/login/pages/Error.stories.tsx b/stories/login/pages/Error.stories.tsx index 7b12410e..e64ea3f8 100644 --- a/stories/login/pages/Error.stories.tsx +++ b/stories/login/pages/Error.stories.tsx @@ -26,3 +26,38 @@ export const WithAnotherMessage: Story = { /> ) }; + +export const WithHtmlErrorMessage: Story = { + render: () => ( + Error: Something went wrong. Go back" + } + }} + /> + ) +}; +export const FrenchError: Story = { + render: () => ( + + ) +}; +export const WithSkipLink: Story = { + render: () => ( + + ) +}; diff --git a/stories/login/pages/FrontchannelLogout.stories.tsx b/stories/login/pages/FrontchannelLogout.stories.tsx index 9dacf0b0..cdef7c2c 100644 --- a/stories/login/pages/FrontchannelLogout.stories.tsx +++ b/stories/login/pages/FrontchannelLogout.stories.tsx @@ -16,3 +16,14 @@ type Story = StoryObj; export const Default: Story = { render: () => }; +export const WithoutRedirectUrl: Story = { + render: () => ( + + ) +}; diff --git a/stories/login/pages/IdpReviewUserProfile.stories.tsx b/stories/login/pages/IdpReviewUserProfile.stories.tsx index 6778e77d..93eb5b30 100644 --- a/stories/login/pages/IdpReviewUserProfile.stories.tsx +++ b/stories/login/pages/IdpReviewUserProfile.stories.tsx @@ -16,3 +16,47 @@ type Story = StoryObj; export const Default: Story = { render: () => }; +export const WithFormValidationErrors: Story = { + render: () => ( + ["email", "firstName"].includes(fieldName), + get: (fieldName: string) => { + if (fieldName === "email") return "Invalid email format."; + if (fieldName === "firstName") return "First name is required."; + } + } + }} + /> + ) +}; +export const WithReadOnlyFields: Story = { + render: () => ( + + ) +}; +export const WithPrefilledFormFields: Story = { + render: () => ( + + ) +}; diff --git a/stories/login/pages/Info.stories.tsx b/stories/login/pages/Info.stories.tsx index 195061f3..81884dce 100644 --- a/stories/login/pages/Info.stories.tsx +++ b/stories/login/pages/Info.stories.tsx @@ -55,3 +55,42 @@ export const WithRequiredActions: Story = { /> ) }; +export const WithPageRedirect: Story = { + render: () => ( + + ) +}; +export const WithoutClientBaseUrl: Story = { + render: () => ( + + ) +}; +export const WithMessageHeader: Story = { + render: () => ( + + ) +}; +export const WithAdvancedMessage: Story = { + render: () => ( + important information." } + }} + /> + ) +}; diff --git a/stories/login/pages/Login.stories.tsx b/stories/login/pages/Login.stories.tsx index 075d1f98..a3743adb 100644 --- a/stories/login/pages/Login.stories.tsx +++ b/stories/login/pages/Login.stories.tsx @@ -231,3 +231,131 @@ export const WithErrorMessage: Story = { /> ) }; + +export const WithOneSocialProvider: Story = { + render: args => ( + + ) +}; + +export const WithTwoSocialProviders: Story = { + render: args => ( + + ) +}; +export const WithNoSocialProviders: Story = { + render: args => ( + + ) +}; +export const WithMoreThanTwoSocialProviders: Story = { + render: args => ( + + ) +}; +export const WithSocialProvidersAndWithoutRememberMe: Story = { + render: args => ( + + ) +}; diff --git a/stories/login/pages/LoginConfigTotp.stories.tsx b/stories/login/pages/LoginConfigTotp.stories.tsx index 5d38df9b..743d38c2 100644 --- a/stories/login/pages/LoginConfigTotp.stories.tsx +++ b/stories/login/pages/LoginConfigTotp.stories.tsx @@ -41,3 +41,24 @@ export const WithError: Story = { /> ) }; +export const WithAppInitiatedAction: Story = { + render: () => ( + + ) +}; + +export const WithPreFilledUserLabel: Story = { + render: () => ( + + ) +}; diff --git a/stories/login/pages/LoginIdpLinkEmail.stories.tsx b/stories/login/pages/LoginIdpLinkEmail.stories.tsx index a58ae2d3..e855eed5 100644 --- a/stories/login/pages/LoginIdpLinkEmail.stories.tsx +++ b/stories/login/pages/LoginIdpLinkEmail.stories.tsx @@ -16,3 +16,49 @@ type Story = StoryObj; export const Default: Story = { render: () => }; +export const WithIdpAlias: Story = { + render: () => ( + + ) +}; +export const WithoutIdpAlias: Story = { + render: () => ( + + ) +}; + +export const WithCustomRealmDisplayName: Story = { + render: () => ( + + ) +}; diff --git a/stories/login/pages/Register.stories.tsx b/stories/login/pages/Register.stories.tsx index 09cdf249..8ab6c16d 100644 --- a/stories/login/pages/Register.stories.tsx +++ b/stories/login/pages/Register.stories.tsx @@ -215,3 +215,65 @@ export const WithTermsAcceptance: Story = { /> ) }; +export const WithTermsNotAccepted: Story = { + render: args => ( + fieldName === "termsAccepted", + get: (fieldName: string) => (fieldName === "termsAccepted" ? "You must accept the terms." : undefined) + } + }} + /> + ) +}; +export const WithFieldErrors: Story = { + render: () => ( + ["username", "email"].includes(fieldName), + get: fieldName => { + if (fieldName === "username") return "Username is required."; + if (fieldName === "email") return "Invalid email format."; + } + } + }} + /> + ) +}; +export const WithReadOnlyFields: Story = { + render: () => ( + + ) +}; +export const WithAutoGeneratedUsername: Story = { + render: () => ( + + ) +}; diff --git a/stories/login/pages/Terms.stories.tsx b/stories/login/pages/Terms.stories.tsx index f5837b07..631fe685 100644 --- a/stories/login/pages/Terms.stories.tsx +++ b/stories/login/pages/Terms.stories.tsx @@ -45,3 +45,32 @@ export const French: Story = { /> ) }; +export const WithErrorMessage: Story = { + render: () => ( + true, + get: () => "An error occurred while processing your request." + } + }} + /> + ) +}; + +export const Spanish: Story = { + render: () => ( + Mis términos en Español

" + } + } + }} + /> + ) +}; From 22241fd7adef1b7e0a209a9dbd9548c348fab7c7 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 21:47:10 +0000 Subject: [PATCH 60/82] docs: update README.md [skip ci] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c6edf2d0..11eff6fe 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Omid
Omid

⚠️ 💻 Katharina Eiserfey
Katharina Eiserfey

💻 ⚠️ 📖 Luca Peruzzo
Luca Peruzzo

💻 ⚠️ + Nima Shokouhfar
Nima Shokouhfar

💻 ⚠️ From 5332001ff42b1c45ca7f922cc027e77bc9bf046f Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 21:47:11 +0000 Subject: [PATCH 61/82] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index fdede216..f7d39edd 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -290,6 +290,16 @@ "code", "test" ] + }, + { + "login": "nima70", + "name": "Nima Shokouhfar", + "avatar_url": "https://avatars.githubusercontent.com/u/5094767?v=4", + "profile": "https://github.com/nima70", + "contributions": [ + "code", + "test" + ] } ], "contributorsPerLine": 7, From 1f4d4473e4d98da052efd77d8d036e060f1ecc7b Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 7 Oct 2024 23:53:05 +0200 Subject: [PATCH 62/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cf03259f..9be229b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.3", + "version": "11.3.4", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 0f99bb5bdc245fc325d63bb71c4eafcf99a6f3b0 Mon Sep 17 00:00:00 2001 From: Liam Lowsley-Williams Date: Tue, 8 Oct 2024 16:50:11 -0500 Subject: [PATCH 63/82] fix: added parameter type for story context on register page --- stories/login/pages/Register.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stories/login/pages/Register.stories.tsx b/stories/login/pages/Register.stories.tsx index 8ab6c16d..3250f927 100644 --- a/stories/login/pages/Register.stories.tsx +++ b/stories/login/pages/Register.stories.tsx @@ -240,8 +240,8 @@ export const WithFieldErrors: Story = { } }, messagesPerField: { - existsError: fieldName => ["username", "email"].includes(fieldName), - get: fieldName => { + existsError: (fieldName: string) => ["username", "email"].includes(fieldName), + get: (fieldName: string) => { if (fieldName === "username") return "Username is required."; if (fieldName === "email") return "Invalid email format."; } From 9ed90995e4cb82a8b2f151762cd349d01aad7be8 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 11 Oct 2024 23:55:04 +0200 Subject: [PATCH 64/82] typesafety fix --- .../updateAccountThemeImplementationInConfig.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/bin/initialize-account-theme/updateAccountThemeImplementationInConfig.ts b/src/bin/initialize-account-theme/updateAccountThemeImplementationInConfig.ts index 1c60ef03..65b3ba39 100644 --- a/src/bin/initialize-account-theme/updateAccountThemeImplementationInConfig.ts +++ b/src/bin/initialize-account-theme/updateAccountThemeImplementationInConfig.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import chalk from "chalk"; import { z } from "zod"; import { id } from "tsafe/id"; +import { is } from "tsafe/is"; export type BuildContextLike = { bundler: BuildContext["bundler"]; @@ -83,6 +84,8 @@ export function updateAccountThemeImplementationInConfig(params: { zParsedPackageJson.parse(parsedPackageJson); + assert(is(parsedPackageJson)); + return parsedPackageJson; })(); From 2917719315176751248b193997a1281893da4897 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 12 Oct 2024 17:30:30 +0200 Subject: [PATCH 65/82] Add dir=rtl attribut to html when using a RTL language --- src/login/KcContext/KcContext.ts | 1 + src/login/i18n/noJsx/getI18n.tsx | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/login/KcContext/KcContext.ts b/src/login/KcContext/KcContext.ts index 14d1169f..161e45de 100644 --- a/src/login/KcContext/KcContext.ts +++ b/src/login/KcContext/KcContext.ts @@ -94,6 +94,7 @@ export declare namespace KcContext { languageTag: string; }[]; currentLanguageTag: string; + rtl?: boolean; }; auth?: { showUsername?: boolean; diff --git a/src/login/i18n/noJsx/getI18n.tsx b/src/login/i18n/noJsx/getI18n.tsx index e560e2e3..f619a303 100644 --- a/src/login/i18n/noJsx/getI18n.tsx +++ b/src/login/i18n/noJsx/getI18n.tsx @@ -19,6 +19,7 @@ export type KcContextLike = { locale?: { currentLanguageTag: string; supported: { languageTag: string; url: string; label: string }[]; + rtl?: boolean; }; "x-keycloakify": { messages: Record; @@ -95,6 +96,49 @@ export function createGetI18n< const html = document.querySelector("html"); assert(html !== null); html.lang = currentLanguageTag; + + const isRtl = (() => { + const { rtl } = kcContext.locale ?? {}; + + if (rtl !== undefined) { + return rtl; + } + + return [ + /* spell-checker: disable */ + // Common RTL languages + "ar", // Arabic + "fa", // Persian (Farsi) + "he", // Hebrew + "ur", // Urdu + "ps", // Pashto + "syr", // Syriac + "dv", // Divehi (Maldivian) + "ku", // Kurdish (Sorani) + "ug", // Uighur + "az", // Azerbaijani (Arabic script) + "sd", // Sindhi + + // Less common RTL languages + "yi", // Yiddish + "ha", // Hausa (when written in Arabic script) + "ks", // Kashmiri (written in the Perso-Arabic script) + "bal", // Balochi (when written in Arabic script) + "khw", // Khowar (Chitrali) + "brh", // Brahui (when written in Arabic script) + "tmh", // Tamashek (some dialects use Arabic script) + "bgn", // Western Balochi + "arc", // Aramaic + "sam", // Samaritan Aramaic + "prd", // Parsi-Dari (a dialect of Persian) + "huz", // Hazaragi (a dialect of Persian) + "gbz", // Zaza (written in Arabic script in some areas) + "urj" // Urdu in Romanized script (not always RTL, but to account for edge cases) + /* spell-checker: enable */ + ].includes(currentLanguageTag); + })(); + + html.dir = isRtl ? "rtl" : "ltr"; } const getLanguageLabel = (languageTag: LanguageTag) => { From e498fb784ba12e5e6be10ca35b95b871edc66bd7 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 12 Oct 2024 17:33:44 +0200 Subject: [PATCH 66/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9be229b0..cfa9d0cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.4", + "version": "11.3.5", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 2c1cca168f0748498a94d9467a6bbc22130da676 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 13 Oct 2024 00:55:06 +0200 Subject: [PATCH 67/82] Resolve package.json path relative to the package.json --- src/bin/shared/buildContext.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/bin/shared/buildContext.ts b/src/bin/shared/buildContext.ts index 26c746b8..89afa6f9 100644 --- a/src/bin/shared/buildContext.ts +++ b/src/bin/shared/buildContext.ts @@ -508,6 +508,15 @@ export function getBuildContext(params: { return themeNames; })(); + const relativePathsCwd = (() => { + switch (bundler) { + case "vite": + return projectDirPath; + case "webpack": + return pathDirname(packageJsonFilePath); + } + })(); + const projectBuildDirPath = (() => { webpack: { if (bundler !== "webpack") { @@ -519,7 +528,7 @@ export function getBuildContext(params: { if (parsedPackageJson.keycloakify.projectBuildDirPath !== undefined) { return getAbsoluteAndInOsFormatPath({ pathIsh: parsedPackageJson.keycloakify.projectBuildDirPath, - cwd: projectDirPath + cwd: relativePathsCwd }); } @@ -563,7 +572,7 @@ export function getBuildContext(params: { if (buildOptions.keycloakifyBuildDirPath !== undefined) { return getAbsoluteAndInOsFormatPath({ pathIsh: buildOptions.keycloakifyBuildDirPath, - cwd: projectDirPath + cwd: relativePathsCwd }); } @@ -592,7 +601,7 @@ export function getBuildContext(params: { if (parsedPackageJson.keycloakify.publicDirPath !== undefined) { return getAbsoluteAndInOsFormatPath({ pathIsh: parsedPackageJson.keycloakify.publicDirPath, - cwd: projectDirPath + cwd: relativePathsCwd }); } @@ -664,7 +673,7 @@ export function getBuildContext(params: { pathIsh: parsedPackageJson.keycloakify .staticDirPathInProjectBuildDirPath, - cwd: projectBuildDirPath + cwd: relativePathsCwd }); } @@ -992,7 +1001,7 @@ export function getBuildContext(params: { type: "path", path: getAbsoluteAndInOsFormatPath({ pathIsh: urlOrPath, - cwd: projectDirPath + cwd: relativePathsCwd }) }; } @@ -1002,7 +1011,7 @@ export function getBuildContext(params: { ? undefined : getAbsoluteAndInOsFormatPath({ pathIsh: buildOptions.startKeycloakOptions.realmJsonFilePath, - cwd: projectDirPath + cwd: relativePathsCwd }), port: buildOptions.startKeycloakOptions?.port } From 27da57844662ea0ad0499daee6c1068b8f179552 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 13 Oct 2024 00:55:23 +0200 Subject: [PATCH 68/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cfa9d0cd..e6259f12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.5", + "version": "11.3.6", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 831326952bcb0fe925e71b249d9340b931e77967 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Wed, 16 Oct 2024 03:37:00 +0200 Subject: [PATCH 69/82] Resize zone2 logo --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 11eff6fe..2b09cd04 100644 --- a/README.md +++ b/README.md @@ -50,19 +50,19 @@ Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyon ## Sponsors -Friends for the project, we trust and recommend their services. +Project backers, we trust and recommend their services.
-![Logo Dark](https://github.com/user-attachments/assets/088f6631-b7ef-42ad-812b-df4870dc16ae#gh-dark-mode-only) +![Logo Dark](https://github.com/user-attachments/assets/dd3925fb-a58a-4e91-b360-69c2fa1f1087)
-![Logo Light](https://github.com/user-attachments/assets/53fb16f8-02ef-4523-9c36-b42d6e59837e#gh-light-mode-only) +![Logo Light](https://github.com/user-attachments/assets/6c00c201-eed7-485a-a887-70891559d69b#gh-light-mode-only)
From 8decf4a3c943cfd5cb54129455e39d3e1a11adea Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Wed, 16 Oct 2024 04:10:17 +0200 Subject: [PATCH 70/82] Add phaseTwo as sponsor --- README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b09cd04..6f3eb47a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,28 @@ Project backers, we trust and recommend their services.
+![Logo Dark](https://github.com/user-attachments/assets/d8f6b6f5-3de4-4adc-ba15-cb4074e8309b) + +
+ +
+ +![Logo Light](https://github.com/user-attachments/assets/20736d6f-f22d-4a9d-9dfe-93be209a8191#gh-light-mode-only) + +
+ +
+ +

+ Keycloak on Steroids as a Service - Keycloak community contributors of popular extensions providing free and dedicated Keycloak hosting and enterprise Keycloak support to businesses of all sizes. +

+ +
+
+
+ +
+ ![Logo Dark](https://github.com/user-attachments/assets/dd3925fb-a58a-4e91-b360-69c2fa1f1087)
@@ -87,7 +109,7 @@ Project backers, we trust and recommend their services.

-Managed Keycloak Provider - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. +Managed Keycloak Provider - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. Cloud IAM is a french company.
Use code keycloakify5 at checkout for a 5% discount.

From de620dca5685e5790da27be3fd4cee6b6b1e2be8 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Wed, 16 Oct 2024 05:13:52 +0200 Subject: [PATCH 71/82] Fix light mode rendering --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6f3eb47a..af64954c 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Project backers, we trust and recommend their services.
-![Logo Dark](https://github.com/user-attachments/assets/d8f6b6f5-3de4-4adc-ba15-cb4074e8309b) +![Logo Dark](https://github.com/user-attachments/assets/d8f6b6f5-3de4-4adc-ba15-cb4074e8309b#gh-dark-mode-only)
@@ -78,7 +78,7 @@ Project backers, we trust and recommend their services.
-![Logo Dark](https://github.com/user-attachments/assets/dd3925fb-a58a-4e91-b360-69c2fa1f1087) +![Logo Dark](https://github.com/user-attachments/assets/dd3925fb-a58a-4e91-b360-69c2fa1f1087#gh-dark-mode-only)
From f5b15a5ef698f7a624527abc97ef194e2917ccce Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Thu, 17 Oct 2024 19:54:14 +0200 Subject: [PATCH 72/82] Fix Phase two links --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index af64954c..81228618 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Project backers, we trust and recommend their services.

- Keycloak on Steroids as a Service - Keycloak community contributors of popular extensions providing free and dedicated Keycloak hosting and enterprise Keycloak support to businesses of all sizes. + Keycloak as a Service - Keycloak community contributors of popular extensions providing free and dedicated Keycloak hosting and enterprise Keycloak support to businesses of all sizes.


From cacd0172448f875098f6da14c5d0e5713ec65237 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Thu, 17 Oct 2024 23:23:26 +0200 Subject: [PATCH 73/82] #696 --- src/bin/initialize-email-theme.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/bin/initialize-email-theme.ts b/src/bin/initialize-email-theme.ts index 7a81205b..62a5566a 100644 --- a/src/bin/initialize-email-theme.ts +++ b/src/bin/initialize-email-theme.ts @@ -5,6 +5,9 @@ import type { BuildContext } from "./shared/buildContext"; import * as fs from "fs"; import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive"; import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate"; +import fetch from "make-fetch-happen"; +import { SemVer } from "./tools/SemVer"; +import { assert } from "tsafe/assert"; export async function command(params: { buildContext: BuildContext }) { const { buildContext } = params; @@ -36,7 +39,7 @@ export async function command(params: { buildContext: BuildContext }) { console.log("Initialize with the base email theme from which version of Keycloak?"); - const { keycloakVersion } = await promptKeycloakVersion({ + let { keycloakVersion } = await promptKeycloakVersion({ // NOTE: This is arbitrary startingFromMajor: 17, excludeMajorVersions: [], @@ -44,8 +47,32 @@ export async function command(params: { buildContext: BuildContext }) { buildContext }); + const getUrl = (keycloakVersion: string) => { + return `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`; + }; + + keycloakVersion = await (async () => { + const keycloakVersionParsed = SemVer.parse(keycloakVersion); + + while (true) { + const url = getUrl(SemVer.stringify(keycloakVersionParsed)); + + const response = await fetch(url, buildContext.fetchOptions); + + if (response.ok) { + break; + } + + assert(keycloakVersionParsed.patch !== 0); + + keycloakVersionParsed.patch--; + } + + return SemVer.stringify(keycloakVersionParsed); + })(); + const { extractedDirPath } = await downloadAndExtractArchive({ - url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`, + url: getUrl(keycloakVersion), cacheDirPath: buildContext.cacheDirPath, fetchOptions: buildContext.fetchOptions, uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme", From f3a97b253869cb082b9b5ceb6cbaec0e14dd1d44 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Thu, 17 Oct 2024 23:23:52 +0200 Subject: [PATCH 74/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6259f12..ccb76d98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.6", + "version": "11.3.7", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From d2e518d96ba09a058eb4923604d1e0fb88cdc2b8 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 19 Oct 2024 02:19:41 +0200 Subject: [PATCH 75/82] #693 #692 --- ...skeysConditionalAuthenticate.useScript.tsx | 9 +++++- ...LoginRecoveryAuthnCodeConfig.useScript.tsx | 9 +++++- .../pages/WebauthnAuthenticate.useScript.tsx | 9 +++++- .../pages/WebauthnRegister.useScript.tsx | 9 +++++- src/tools/waitForElementMountedOnDom.ts | 30 +++++++++++++++++++ 5 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 src/tools/waitForElementMountedOnDom.ts diff --git a/src/login/pages/LoginPasskeysConditionalAuthenticate.useScript.tsx b/src/login/pages/LoginPasskeysConditionalAuthenticate.useScript.tsx index 9cd1c867..4807c6c1 100644 --- a/src/login/pages/LoginPasskeysConditionalAuthenticate.useScript.tsx +++ b/src/login/pages/LoginPasskeysConditionalAuthenticate.useScript.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; import { assert } from "keycloakify/tools/assert"; import { KcContext } from "keycloakify/login/KcContext/KcContext"; +import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom"; type KcContextLike = { url: { @@ -67,6 +68,12 @@ export function useScript(params: { authButtonId: string; kcContext: KcContextLi return; } - insertScriptTags(); + (async () => { + await waitForElementMountedOnDom({ + elementId: authButtonId + }); + + insertScriptTags(); + })(); }, [isFetchingTranslations]); } diff --git a/src/login/pages/LoginRecoveryAuthnCodeConfig.useScript.tsx b/src/login/pages/LoginRecoveryAuthnCodeConfig.useScript.tsx index 8a5f9586..ef882462 100644 --- a/src/login/pages/LoginRecoveryAuthnCodeConfig.useScript.tsx +++ b/src/login/pages/LoginRecoveryAuthnCodeConfig.useScript.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom"; type I18nLike = { msgStr: (key: "recovery-codes-download-file-header" | "recovery-codes-download-file-description" | "recovery-codes-download-file-date") => string; @@ -137,6 +138,12 @@ export function useScript(params: { olRecoveryCodesListId: string; i18n: I18nLik return; } - insertScriptTags(); + (async () => { + await waitForElementMountedOnDom({ + elementId: olRecoveryCodesListId + }); + + insertScriptTags(); + })(); }, [isFetchingTranslations]); } diff --git a/src/login/pages/WebauthnAuthenticate.useScript.tsx b/src/login/pages/WebauthnAuthenticate.useScript.tsx index bc8fde5a..0ffde595 100644 --- a/src/login/pages/WebauthnAuthenticate.useScript.tsx +++ b/src/login/pages/WebauthnAuthenticate.useScript.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; import { assert } from "keycloakify/tools/assert"; import { KcContext } from "keycloakify/login/KcContext/KcContext"; +import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom"; type KcContextLike = { url: { @@ -59,6 +60,12 @@ export function useScript(params: { authButtonId: string; kcContext: KcContextLi return; } - insertScriptTags(); + (async () => { + await waitForElementMountedOnDom({ + elementId: authButtonId + }); + + insertScriptTags(); + })(); }, [isFetchingTranslations]); } diff --git a/src/login/pages/WebauthnRegister.useScript.tsx b/src/login/pages/WebauthnRegister.useScript.tsx index c1d8cd66..98f3dfa7 100644 --- a/src/login/pages/WebauthnRegister.useScript.tsx +++ b/src/login/pages/WebauthnRegister.useScript.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; import { assert } from "keycloakify/tools/assert"; import { KcContext } from "keycloakify/login/KcContext/KcContext"; +import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom"; type KcContextLike = { url: { @@ -88,6 +89,12 @@ export function useScript(params: { authButtonId: string; kcContext: KcContextLi return; } - insertScriptTags(); + (async () => { + await waitForElementMountedOnDom({ + elementId: authButtonId + }); + + insertScriptTags(); + })(); }, [isFetchingTranslations]); } diff --git a/src/tools/waitForElementMountedOnDom.ts b/src/tools/waitForElementMountedOnDom.ts new file mode 100644 index 00000000..08934f74 --- /dev/null +++ b/src/tools/waitForElementMountedOnDom.ts @@ -0,0 +1,30 @@ +export async function waitForElementMountedOnDom(params: { + elementId: string; +}): Promise { + const { elementId } = params; + + const getElement = () => document.getElementById(elementId); + + const element = getElement(); + + if (element === null) { + let prElementPresentInTheDom_resolve: () => void; + const prElementPresentInTheDom = new Promise( + resolve => (prElementPresentInTheDom_resolve = resolve) + ); + + // Observe the dom for the element to be added + const observer = new MutationObserver(() => { + const element = getElement(); + if (element === null) { + return; + } + observer.disconnect(); + prElementPresentInTheDom_resolve(); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + await prElementPresentInTheDom; + } +} From dda77952a0b585bbe8bfdf46c8c59d8957494d35 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 19 Oct 2024 02:28:11 +0200 Subject: [PATCH 76/82] #694 Probably some shell handle double quote differently --- src/bin/start-keycloak/start-keycloak.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bin/start-keycloak/start-keycloak.ts b/src/bin/start-keycloak/start-keycloak.ts index e243fe2b..4e74bed6 100644 --- a/src/bin/start-keycloak/start-keycloak.ts +++ b/src/bin/start-keycloak/start-keycloak.ts @@ -396,12 +396,12 @@ export async function command(params: { ...(realmJsonFilePath === undefined ? [] : [ - `-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), realmJsonFilePath)}":/opt/keycloak/data/import/myrealm-realm.json` + `-v${SPACE_PLACEHOLDER}"${realmJsonFilePath}":/opt/keycloak/data/import/myrealm-realm.json` ]), - `-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath_cacheDir)}":/opt/keycloak/providers/keycloak-theme.jar`, + `-v${SPACE_PLACEHOLDER}"${jarFilePath_cacheDir}":/opt/keycloak/providers/keycloak-theme.jar`, ...extensionJarFilePaths.map( jarFilePath => - `-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath)}":/opt/keycloak/providers/${pathBasename(jarFilePath)}` + `-v${SPACE_PLACEHOLDER}"${jarFilePath}":/opt/keycloak/providers/${pathBasename(jarFilePath)}` ), ...(keycloakMajorVersionNumber <= 20 ? [`-e${SPACE_PLACEHOLDER}JAVA_OPTS=-Dkeycloak.profile=preview`] @@ -424,7 +424,7 @@ export async function command(params: { })) .map( ({ localDirPath, containerDirPath }) => - `-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), localDirPath)}":${containerDirPath}:rw` + `-v${SPACE_PLACEHOLDER}"${localDirPath}":${containerDirPath}:rw` ), ...buildContext.environmentVariables .map(({ name }) => ({ name, envValue: process.env[name] })) From 3a2fe597bad224d08056e9412ca7552bec6f2c05 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 19 Oct 2024 02:28:32 +0200 Subject: [PATCH 77/82] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ccb76d98..67c0865f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.7", + "version": "11.3.8", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From ba0532c95daf7e0bf21ccd71158b8775f86c72c5 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 19 Oct 2024 00:24:36 +0000 Subject: [PATCH 78/82] docs: update README.md [skip ci] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 81228618..9894e35a 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Katharina Eiserfey
Katharina Eiserfey

💻 ⚠️ 📖 Luca Peruzzo
Luca Peruzzo

💻 ⚠️ Nima Shokouhfar
Nima Shokouhfar

💻 ⚠️ + Marvin A. Ruder
Marvin A. Ruder

🐛 From 4273322ed59873d46494c7d3d4c091d134f284d0 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 19 Oct 2024 00:24:37 +0000 Subject: [PATCH 79/82] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index f7d39edd..64b2924f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -300,6 +300,15 @@ "code", "test" ] + }, + { + "login": "marvinruder", + "name": "Marvin A. Ruder", + "avatar_url": "https://avatars.githubusercontent.com/u/18495294?v=4", + "profile": "https://mruder.dev", + "contributions": [ + "bug" + ] } ], "contributorsPerLine": 7, From 4185188a5b4fabe60d5966471fab5b46906f57eb Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 19 Oct 2024 22:28:10 +0200 Subject: [PATCH 80/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 67c0865f..e6a3de65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.8", + "version": "11.3.9-rc.0", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git", From 19da96113f832d087c1d0f361a4f34ed8d3e88f5 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 19 Oct 2024 22:33:08 +0200 Subject: [PATCH 81/82] Don't export internals --- src/login/lib/getUserProfileApi/getUserProfileApi.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/login/lib/getUserProfileApi/getUserProfileApi.ts b/src/login/lib/getUserProfileApi/getUserProfileApi.ts index 31230ad3..0a695217 100644 --- a/src/login/lib/getUserProfileApi/getUserProfileApi.ts +++ b/src/login/lib/getUserProfileApi/getUserProfileApi.ts @@ -145,9 +145,7 @@ namespace internal { }; } -export function getUserProfileApi_noCache( - params: ParamsOfGetUserProfileApi -): UserProfileApi { +function getUserProfileApi_noCache(params: ParamsOfGetUserProfileApi): UserProfileApi { const { kcContext, doMakeUserConfirmPassword } = params; unFormatNumberOnSubmit(); From 25920c208d6e9b5ab267039a51298f403ea3cbb5 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 19 Oct 2024 22:33:24 +0200 Subject: [PATCH 82/82] Release candidate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6a3de65..a692db5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "11.3.9-rc.0", + "version": "11.3.9-rc.1", "description": "Framework to create custom Keycloak UIs", "repository": { "type": "git",