diff --git a/package.json b/package.json index 1b99f1b3..04819556 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "types": "dist/index.d.ts", "scripts": { "build": "rimraf dist/ && tsc -p src/bin && tsc -p src && yarn grant-exec-perms && yarn copy-files dist/", - "build:test": "rimraf dist_test/ && tsc -p src/test && yarn copy-files dist_test/", + "build:test": "rimraf dist_test/ && tsc -p test && yarn copy-files dist_test/", "grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js", "copy-files": "copyfiles -u 1 src/**/*.ftl", "pretest": "yarn build:test", diff --git a/src/KcApp.tsx b/src/KcApp.tsx index 4b6c2671..e5ca6a8f 100644 --- a/src/KcApp.tsx +++ b/src/KcApp.tsx @@ -1,32 +1,33 @@ -import React, { lazy, Suspense } from "react"; +import { lazy, Suspense } from "react"; import { __unsafe_useI18n as useI18n } from "./i18n"; -import DefaultTemplate from "./Template"; import type { KcContextBase } from "./kcContext/KcContextBase"; -import type { PageProps } from "./KcProps"; +import type { PageProps } from "keycloakify/pages/PageProps"; import type { I18nBase } from "./i18n"; import type { SetOptional } from "./tools/SetOptional"; -const Login = lazy(() => import("./pages/Login")); -const Register = lazy(() => import("./pages/Register")); -const RegisterUserProfile = lazy(() => import("./pages/RegisterUserProfile")); -const Info = lazy(() => import("./pages/Info")); -const Error = lazy(() => import("./pages/Error")); -const LoginResetPassword = lazy(() => import("./pages/LoginResetPassword")); -const LoginVerifyEmail = lazy(() => import("./pages/LoginVerifyEmail")); -const Terms = lazy(() => import("./pages/Terms")); -const LoginOtp = lazy(() => import("./pages/LoginOtp")); -const LoginPassword = lazy(() => import("./pages/LoginPassword")); -const LoginUsername = lazy(() => import("./pages/LoginUsername")); -const WebauthnAuthenticate = lazy(() => import("./pages/WebauthnAuthenticate")); -const LoginUpdatePassword = lazy(() => import("./pages/LoginUpdatePassword")); -const LoginUpdateProfile = lazy(() => import("./pages/LoginUpdateProfile")); -const LoginIdpLinkConfirm = lazy(() => import("./pages/LoginIdpLinkConfirm")); -const LoginPageExpired = lazy(() => import("./pages/LoginPageExpired")); -const LoginIdpLinkEmail = lazy(() => import("./pages/LoginIdpLinkEmail")); -const LoginConfigTotp = lazy(() => import("./pages/LoginConfigTotp")); -const LogoutConfirm = lazy(() => import("./pages/LogoutConfirm")); -const UpdateUserProfile = lazy(() => import("./pages/UpdateUserProfile")); -const IdpReviewUserProfile = lazy(() => import("./pages/IdpReviewUserProfile")); +const DefaultTemplate = lazy(() => import("keycloakify/Template")); + +const Login = lazy(() => import("keycloakify/pages/Login")); +const Register = lazy(() => import("keycloakify/pages/Register")); +const RegisterUserProfile = lazy(() => import("keycloakify/pages/RegisterUserProfile")); +const Info = lazy(() => import("keycloakify/pages/Info")); +const Error = lazy(() => import("keycloakify/pages/Error")); +const LoginResetPassword = lazy(() => import("keycloakify/pages/LoginResetPassword")); +const LoginVerifyEmail = lazy(() => import("keycloakify/pages/LoginVerifyEmail")); +const Terms = lazy(() => import("keycloakify/pages/Terms")); +const LoginOtp = lazy(() => import("keycloakify/pages/LoginOtp")); +const LoginPassword = lazy(() => import("keycloakify/pages/LoginPassword")); +const LoginUsername = lazy(() => import("keycloakify/pages/LoginUsername")); +const WebauthnAuthenticate = lazy(() => import("keycloakify/pages/WebauthnAuthenticate")); +const LoginUpdatePassword = lazy(() => import("keycloakify/pages/LoginUpdatePassword")); +const LoginUpdateProfile = lazy(() => import("keycloakify/pages/LoginUpdateProfile")); +const LoginIdpLinkConfirm = lazy(() => import("keycloakify/pages/LoginIdpLinkConfirm")); +const LoginPageExpired = lazy(() => import("keycloakify/pages/LoginPageExpired")); +const LoginIdpLinkEmail = lazy(() => import("keycloakify/pages/LoginIdpLinkEmail")); +const LoginConfigTotp = lazy(() => import("keycloakify/pages/LoginConfigTotp")); +const LogoutConfirm = lazy(() => import("keycloakify/pages/LogoutConfirm")); +const UpdateUserProfile = lazy(() => import("keycloakify/pages/UpdateUserProfile")); +const IdpReviewUserProfile = lazy(() => import("keycloakify/pages/IdpReviewUserProfile")); export default function KcApp(props_: SetOptional, "Template">) { const { kcContext, i18n: userProvidedI18n, Template = DefaultTemplate, ...kcProps } = props_; diff --git a/src/Template.tsx b/src/Template.tsx index 481a4e91..4ee2e0a9 100644 --- a/src/Template.tsx +++ b/src/Template.tsx @@ -2,7 +2,7 @@ import { assert } from "keycloakify/tools/assert"; import { clsx } from "keycloakify/tools/clsx"; import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate"; import { type TemplateProps, defaultTemplateClasses } from "keycloakify/TemplateProps"; -import { useGetClassName } from "keycloakify/lib/getClassName"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; type KcContext = import("./kcContext/KcContextBase").KcContextBase.Common.Login; import type { I18nBase as I18n } from "./i18n"; diff --git a/src/TemplateProps.ts b/src/TemplateProps.ts index 172a0668..53c9cba3 100644 --- a/src/TemplateProps.ts +++ b/src/TemplateProps.ts @@ -5,6 +5,10 @@ import type { I18nBase } from "keycloakify/i18n"; export type TemplateProps = { kcContext: KcContext; i18n: I18n; + doUseDefaultCss: boolean; + classes?: Partial>; + + formNode: ReactNode; displayInfo?: boolean; displayMessage?: boolean; displayRequiredFields?: boolean; @@ -12,10 +16,7 @@ export type TemplateProps>; }; export type TemplateClassKey = diff --git a/src/kcContext/KcContextBase.ts b/src/kcContext/KcContextBase.ts index f42f6b51..771473b1 100644 --- a/src/kcContext/KcContextBase.ts +++ b/src/kcContext/KcContextBase.ts @@ -2,7 +2,6 @@ import type { LoginThemePageId, AccountThemePageId } from "../bin/keycloakify/ge import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; import type { MessageKeyBase } from "../i18n"; -import type { KcTemplateClassKey } from "../templates/LoginThemeTemplate"; type ExtractAfterStartingWith = StrEnum extends `${Prefix}${infer U}` ? U : never; @@ -289,7 +288,7 @@ export declare namespace KcContextBase { export type WebauthnAuthenticator = { credentialId: string; transports: { - iconClass: KcTemplateClassKey; + iconClass: string; displayNameProperties: MessageKeyBase[]; }; label: string; diff --git a/src/kcContext/getKcContextFromWindow.ts b/src/kcContext/getKcContextFromWindow.ts index 93d2aeab..852e3cd0 100644 --- a/src/kcContext/getKcContextFromWindow.ts +++ b/src/kcContext/getKcContextFromWindow.ts @@ -4,7 +4,7 @@ import { ftlValuesGlobalName } from "../bin/keycloakify/ftlValuesGlobalName"; export type ExtendsKcContextBase = [KcContextExtended] extends [never] ? KcContextBase - : AndByDiscriminatingKey<"pageId", KcContextExtended & KcContextBase.Common, KcContextBase>; + : AndByDiscriminatingKey<"pageId", KcContextExtended & KcContextBase.Common.Login, KcContextBase>; export function getKcContextFromWindow(): ExtendsKcContextBase | undefined { return typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName]; diff --git a/src/kcContext/kcContextMocks.ts b/src/kcContext/kcContextMocks.ts index b214eba0..f7a5a0e6 100644 --- a/src/kcContext/kcContextMocks.ts +++ b/src/kcContext/kcContextMocks.ts @@ -101,7 +101,7 @@ const attributes: Attribute[] = [ const attributesByName = Object.fromEntries(attributes.map(attribute => [attribute.name, attribute])) as any; -export const kcContextCommonMock: KcContextBase.Common = { +export const kcContextCommonMock: KcContextBase.Common.Login = { "url": { "loginAction": "#", "resourcesPath": pathJoin(PUBLIC_URL, mockTestingResourcesPath), @@ -268,7 +268,7 @@ export const kcContextMocks: KcContextBase[] = [ "registrationDisabled": false }), ...(() => { - const registerCommon: KcContextBase.RegisterCommon = { + const registerCommon: KcContextBase.RegisterUserProfile.CommonWithLegacy = { ...kcContextCommonMock, "url": { ...loginUrl, diff --git a/src/lib/useDownloadTerms.ts b/src/lib/useDownloadTerms.ts index a0a5da06..20ece8f7 100644 --- a/src/lib/useDownloadTerms.ts +++ b/src/lib/useDownloadTerms.ts @@ -4,15 +4,19 @@ import { fallbackLanguageTag } from "../i18n"; import { useConst } from "../tools/useConst"; import { useConstCallback } from "../tools/useConstCallback"; import { assert } from "tsafe/assert"; +import { KcContextBase as KcContext } from "../kcContext"; +import { Evt } from "evt"; + +export const evtTermMarkdown = Evt.create(undefined); export type KcContextLike = { - pageId: KcContextBase["pageId"]; + pageId: KcContext["pageId"]; locale?: { currentLanguageTag: string; }; }; -assert(); +assert(); /** Allow to avoid bundling the terms and download it on demand*/ export function useDownloadTerms(params: { diff --git a/src/lib/useFormValidation.tsx b/src/lib/useFormValidation.tsx new file mode 100644 index 00000000..57ea6205 --- /dev/null +++ b/src/lib/useFormValidation.tsx @@ -0,0 +1,483 @@ +import "keycloakify/tools/Array.prototype.every"; +import { useMemo, useReducer, Fragment } from "react"; +import { id } from "tsafe/id"; +import type { MessageKeyBase } from "keycloakify/i18n"; +import type { Attribute, Validators } from "keycloakify/kcContext"; +import { useConstCallback } from "keycloakify/tools/useConstCallback"; +import { emailRegexp } from "keycloakify/tools/emailRegExp"; +import type { KcContextBase as KcContext } from "../kcContext"; +import type { I18nBase as I18n } from "../i18n"; + +/** + * NOTE: The attributesWithPassword returned is actually augmented with + * artificial password related attributes only if kcContext.passwordRequired === true + */ +export function useFormValidation(params: { + kcContext: { + messagesPerField: Pick; + profile: { + attributes: Attribute[]; + }; + passwordRequired?: boolean; + realm: { registrationEmailAsUsername: boolean }; + }; + /** NOTE: Try to avoid passing a new ref every render for better performances. */ + passwordValidators?: Validators; + i18n: I18n; +}) { + const { + kcContext, + passwordValidators = { + "length": { + "ignore.empty.value": true, + "min": "4" + } + }, + i18n + } = params; + + const attributesWithPassword = useMemo( + () => + !kcContext.passwordRequired + ? kcContext.profile.attributes + : (() => { + const name = kcContext.realm.registrationEmailAsUsername ? "email" : "username"; + + return kcContext.profile.attributes.reduce( + (prev, curr) => [ + ...prev, + ...(curr.name !== name + ? [curr] + : [ + curr, + id({ + "name": "password", + "displayName": id<`\${${MessageKeyBase}}`>("${password}"), + "required": true, + "readOnly": false, + "validators": passwordValidators, + "annotations": {}, + "groupAnnotations": {}, + "autocomplete": "new-password" + }), + id({ + "name": "password-confirm", + "displayName": id<`\${${MessageKeyBase}}`>("${passwordConfirm}"), + "required": true, + "readOnly": false, + "validators": { + "_compareToOther": { + "name": "password", + "ignore.empty.value": true, + "shouldBe": "equal", + "error-message": id<`\${${MessageKeyBase}}`>("${invalidPasswordConfirmMessage}") + } + }, + "annotations": {}, + "groupAnnotations": {}, + "autocomplete": "new-password" + }) + ]) + ], + [] + ); + })(), + [kcContext, passwordValidators] + ); + + const { getErrors } = useGetErrors({ + "kcContext": { + "messagesPerField": kcContext.messagesPerField, + "profile": { + "attributes": attributesWithPassword + } + }, + i18n + }); + + const initialInternalState = useMemo( + () => + Object.fromEntries( + attributesWithPassword + .map(attribute => ({ + attribute, + "errors": getErrors({ + "name": attribute.name, + "fieldValueByAttributeName": Object.fromEntries( + attributesWithPassword.map(({ name, value }) => [name, { "value": value ?? "" }]) + ) + }) + })) + .map(({ attribute, errors }) => [ + attribute.name, + { + "value": attribute.value ?? "", + errors, + "doDisplayPotentialErrorMessages": errors.length !== 0 + } + ]) + ), + [attributesWithPassword] + ); + + type InternalState = typeof initialInternalState; + + const [formValidationInternalState, formValidationDispatch] = useReducer( + ( + state: InternalState, + params: + | { + action: "update value"; + name: string; + newValue: string; + } + | { + action: "focus lost"; + name: string; + } + ): InternalState => ({ + ...state, + [params.name]: { + ...state[params.name], + ...(() => { + switch (params.action) { + case "focus lost": + return { "doDisplayPotentialErrorMessages": true }; + case "update value": + return { + "value": params.newValue, + "errors": getErrors({ + "name": params.name, + "fieldValueByAttributeName": { + ...state, + [params.name]: { "value": params.newValue } + } + }) + }; + } + })() + } + }), + initialInternalState + ); + + const formValidationState = useMemo( + () => ({ + "fieldStateByAttributeName": Object.fromEntries( + Object.entries(formValidationInternalState).map(([name, { value, errors, doDisplayPotentialErrorMessages }]) => [ + name, + { value, "displayableErrors": doDisplayPotentialErrorMessages ? errors : [] } + ]) + ), + "isFormSubmittable": Object.entries(formValidationInternalState).every( + ([name, { value, errors }]) => + errors.length === 0 && (value !== "" || !attributesWithPassword.find(attribute => attribute.name === name)!.required) + ) + }), + [formValidationInternalState, attributesWithPassword] + ); + + return { + formValidationState, + formValidationDispatch, + attributesWithPassword + }; +} + +/** Expect to be used in a component wrapped within a */ +function useGetErrors(params: { + kcContext: { + messagesPerField: Pick; + profile: { + attributes: { name: string; value?: string; validators: Validators }[]; + }; + }; + i18n: I18n; +}) { + const { kcContext, i18n } = params; + + const { + messagesPerField, + profile: { attributes } + } = kcContext; + + const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n; + + const getErrors = useConstCallback((params: { name: string; fieldValueByAttributeName: Record }) => { + const { name, fieldValueByAttributeName } = params; + + const { value } = fieldValueByAttributeName[name]; + + const { value: defaultValue, validators } = attributes.find(attribute => attribute.name === name)!; + + block: { + if (defaultValue !== value) { + break block; + } + + let doesErrorExist: boolean; + + try { + doesErrorExist = messagesPerField.existsError(name); + } catch { + break block; + } + + if (!doesErrorExist) { + break block; + } + + const errorMessageStr = messagesPerField.get(name); + + return [ + { + "validatorName": undefined, + errorMessageStr, + "errorMessage": {errorMessageStr} + } + ]; + } + + const errors: { + errorMessage: JSX.Element; + errorMessageStr: string; + validatorName: keyof Validators | undefined; + }[] = []; + + scope: { + const validatorName = "length"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break scope; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; + + if (ignoreEmptyValue && value === "") { + break scope; + } + + if (max !== undefined && value.length > parseInt(max)) { + const msgArgs = ["error-invalid-length-too-long", max] as const; + + errors.push({ + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs), + validatorName + }); + } + + if (min !== undefined && value.length < parseInt(min)) { + const msgArgs = ["error-invalid-length-too-short", min] as const; + + errors.push({ + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs), + validatorName + }); + } + } + + scope: { + const validatorName = "_compareToOther"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break scope; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, name: otherName, shouldBe, "error-message": errorMessageKey } = validator; + + if (ignoreEmptyValue && value === "") { + break scope; + } + + const { value: otherValue } = fieldValueByAttributeName[otherName]; + + const isValid = (() => { + switch (shouldBe) { + case "different": + return otherValue !== value; + case "equal": + return otherValue === value; + } + })(); + + if (isValid) { + break scope; + } + + const msgArg = [ + errorMessageKey ?? + id( + (() => { + switch (shouldBe) { + case "equal": + return "shouldBeEqual"; + case "different": + return "shouldBeDifferent"; + } + })() + ), + otherName, + name, + shouldBe + ] as const; + + errors.push({ + validatorName, + "errorMessage": {advancedMsg(...msgArg)}, + "errorMessageStr": advancedMsgStr(...msgArg) + }); + } + + scope: { + const validatorName = "pattern"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break scope; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator; + + if (ignoreEmptyValue && value === "") { + break scope; + } + + if (new RegExp(pattern).test(value)) { + break scope; + } + + const msgArgs = [errorMessageKey ?? id("shouldMatchPattern"), pattern] as const; + + errors.push({ + validatorName, + "errorMessage": {advancedMsg(...msgArgs)}, + "errorMessageStr": advancedMsgStr(...msgArgs) + }); + } + + scope: { + if ([...errors].reverse()[0]?.validatorName === "pattern") { + break scope; + } + + const validatorName = "email"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break scope; + } + + const { "ignore.empty.value": ignoreEmptyValue = false } = validator; + + if (ignoreEmptyValue && value === "") { + break scope; + } + + if (emailRegexp.test(value)) { + break scope; + } + + const msgArgs = [id("invalidEmailMessage")] as const; + + errors.push({ + validatorName, + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs) + }); + } + + scope: { + const validatorName = "integer"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break scope; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; + + if (ignoreEmptyValue && value === "") { + break scope; + } + + const intValue = parseInt(value); + + if (isNaN(intValue)) { + const msgArgs = ["mustBeAnInteger"] as const; + + errors.push({ + validatorName, + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs) + }); + + break scope; + } + + if (max !== undefined && intValue > parseInt(max)) { + const msgArgs = ["error-number-out-of-range-too-big", max] as const; + + errors.push({ + validatorName, + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs) + }); + + break scope; + } + + if (min !== undefined && intValue < parseInt(min)) { + const msgArgs = ["error-number-out-of-range-too-small", min] as const; + + errors.push({ + validatorName, + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs) + }); + + break scope; + } + } + + scope: { + const validatorName = "options"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break scope; + } + + if (value === "") { + break scope; + } + + if (validator.options.indexOf(value) >= 0) { + break scope; + } + + const msgArgs = [id("notAValidOption")] as const; + + errors.push({ + validatorName, + "errorMessage": {advancedMsg(...msgArgs)}, + "errorMessageStr": advancedMsgStr(...msgArgs) + }); + } + + //TODO: Implement missing validators. + + return errors; + }); + + return { getErrors }; +} diff --git a/src/lib/getClassName.ts b/src/lib/useGetClassName.ts similarity index 71% rename from src/lib/getClassName.ts rename to src/lib/useGetClassName.ts index 39cafd6c..df106049 100644 --- a/src/lib/getClassName.ts +++ b/src/lib/useGetClassName.ts @@ -1,4 +1,5 @@ import { clsx } from "keycloakify/tools/clsx"; +import { useConstCallback } from "keycloakify/tools/useConstCallback"; export function useGetClassName(params: { defaultClasses?: Record; @@ -6,9 +7,9 @@ export function useGetClassName(params: { }) { const { defaultClasses, classes } = params; - const getClassName = (classKey: ClassKey): string => { + const getClassName = useConstCallback((classKey: ClassKey): string => { return clsx(classKey, defaultClasses?.[classKey], classes?.[classKey]); - }; + }); return { getClassName }; } diff --git a/src/pages/Error.tsx b/src/pages/Error.tsx index ee9548de..477ca9ed 100644 --- a/src/pages/Error.tsx +++ b/src/pages/Error.tsx @@ -1,10 +1,9 @@ -import React from "react"; -import type { PageProps } from "../KcProps"; -import type { KcContextBase } from "../kcContext"; -import type { I18nBase } from "../i18n"; +import { type PageProps } from "keycloakify/pages/PageProps"; +import type { KcContextBase as KcContext } from "../kcContext"; +import type { I18nBase as I18n } from "../i18n"; -export default function Error(props: PageProps, I18nBase>) { - const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props; +export default function Error(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; const { message, client } = kcContext; @@ -12,7 +11,7 @@ export default function Error(props: PageProps, I18nBase>) { - const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props; +export default function IdpReviewUserProfile(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { getClassName } = useGetClassName({ + "defaultClasses": !doUseDefaultCss ? undefined : defaultClasses, + classes + }); const { msg, msgStr } = i18n; @@ -16,23 +22,27 @@ export default function IdpReviewUserProfile(props: PageProps - - -
-
-
+
+ +
+
+
-
+
, I18nBase>) { - const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props; +export default function Info(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; const { msgStr, msg } = i18n; @@ -15,7 +14,7 @@ export default function Info(props: PageProps{messageHeader} : <>{message.summary}} formNode={ diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index f006d391..3edc7c0d 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,12 +1,18 @@ -import React, { useState, type FormEventHandler } from "react"; -import { clsx } from "../tools/clsx"; +import { useState, type FormEventHandler } from "react"; +import { clsx } from "keycloakify/tools/clsx"; import { useConstCallback } from "../tools/useConstCallback"; -import type { PageProps } from "../KcProps"; -import type { KcContextBase } from "../kcContext"; -import type { I18nBase } from "../i18n"; +import { type PageProps, defaultClasses } from "keycloakify/pages/PageProps"; +import { useGetClassName } from "keycloakify/lib/useGetClassName"; +import type { KcContextBase as KcContext } from "../kcContext"; +import type { I18nBase as I18n } from "../i18n"; -export default function Login(props: PageProps, I18nBase>) { - const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props; +export default function Login(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { getClassName } = useGetClassName({ + "defaultClasses": !doUseDefaultCss ? undefined : defaultClasses, + classes + }); const { social, realm, url, usernameEditDisabled, login, auth, registrationDisabled } = kcContext; @@ -30,21 +36,22 @@ export default function Login(props: PageProps +
{realm.password && ( -
+
{(() => { const label = !realm.loginWithEmailAllowed ? "username" @@ -56,13 +63,13 @@ export default function Login(props: PageProps -