diff --git a/src/login/KcPage.tsx b/src/login/KcPage.tsx index 34b7ac7..80763b7 100644 --- a/src/login/KcPage.tsx +++ b/src/login/KcPage.tsx @@ -86,28 +86,13 @@ export default function KcPage(props: { kcContext: KcContext }) { ); case "register.ftl": return ( - + UserProfileFormFields={UserProfileFormFields} + doMakeUserConfirmPassword={doMakeUserConfirmPassword} + /> ); case "error.ftl": @@ -184,23 +169,13 @@ export default function KcPage(props: { kcContext: KcContext }) { ); case "login-update-profile.ftl": return ( - + ); case "login-oauth2-device-verify-user-code.ftl": return ( diff --git a/src/login/ParticlesBackground.tsx b/src/login/ParticlesBackground.tsx index a0da0ca..9faa7bb 100644 --- a/src/login/ParticlesBackground.tsx +++ b/src/login/ParticlesBackground.tsx @@ -6,7 +6,11 @@ import { type Container, type ISourceOptions } from "@tsparticles/engine"; import { loadSlim } from "@tsparticles/slim"; // if you are going to use `loadSlim`, install the "@tsparticles/slim" package too. // import { loadBasic } from "@tsparticles/basic"; // if you are going to use `loadBasic`, install the "@tsparticles/basic" package too. -const ParticlesBackground = () => { +type ParticlesBackgroundProps = { + darkMode?: boolean; +}; + +const ParticlesBackground = ({ darkMode = false }: ParticlesBackgroundProps) => { const [init, setInit] = useState(false); // this should be run only once per application lifetime @@ -28,7 +32,85 @@ const ParticlesBackground = () => { console.log(container); }; - const options: ISourceOptions = useMemo( + const darkOptions: ISourceOptions = useMemo( + () => ({ + background: { + color: { + value: "#FF00A1" + } + }, + fpsLimit: 120, + interactivity: { + events: { + onClick: { + enable: false, + mode: "push" + }, + onHover: { + enable: true, + mode: "repulse" + } + }, + modes: { + push: { + quantity: 4 + }, + repulse: { + distance: 100, + duration: 5 + } + } + }, + particles: { + color: { + value: [ + "#210014FF", + "#00033BFF", + "#001F24FF", + "#170035FF", + "#000333FF", + "#210014FF" + ] + }, + links: { + color: "#ffffff", + distance: 150, + enable: true, + opacity: 0.0, + width: 0 + }, + move: { + direction: "none", + enable: true, + outModes: { + default: "out" + }, + random: true, + speed: 1, + straight: false + }, + number: { + density: { + enable: true + }, + value: 400 + }, + opacity: { + value: 1 + }, + shape: { + type: "circle" + }, + size: { + value: { min: 100, max: 300 } + } + }, + detectRetina: true + }), + [] + ); + + const lightOptions: ISourceOptions = useMemo( () => ({ background: { color: { @@ -119,11 +201,11 @@ const ParticlesBackground = () => {
) { const { displayInfo = false, displayMessage = true, - displayRequiredFields = false, headerNode, infoNode = null, documentTitle, @@ -98,11 +97,51 @@ export default function Template(props: TemplateProps) { })) }; + const clientInfo = ( + + {client.attributes.logoUri ? ( + {client.name + ) : ( + {client.name + )} +
+ {client.name} +
+
+ ); + + const showTryAnotherWayLink = ( +
+ + + { + document.forms["kc-select-try-another-way-form" as never].submit(); + return false; + }} + > + {msg("doTryAnotherWay")} + +
+ ); + + const showMessage = displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction); + + const showImutableUsername = auth !== undefined && auth.showUsername && !auth.showResetCredentials; + return ( ) { }} > - + {loading == true ? (
@@ -126,20 +165,31 @@ export default function Template(props: TemplateProps) { > {!isMobile && ( - Logo + <> + {darkMode ? ( + Logo + ) : ( + Logo + )} + )} ) { } }} > - -
- {isMobile && ( + + {isMobile && ( + + Logo{" "} + + + )} + + + {headerNode && ( <> - Logo{" "} - + + {headerNode} + + {isMobile && } )} -
- - - {headerNode} - - {client.attributes.logoUri && ( - {client.name - )} - - {(() => { - const node = - auth !== undefined && auth.showUsername && !auth.showResetCredentials ? ( - - - - {auth.attemptedUsername} - - {msg("restartLoginTooltip")} - - - - ) : null; - - if (displayRequiredFields) { - return ( - <> - {node} -
- - * {msg("requiredFields")} - -
- - ); - } - - return node; - })()} -
- - {/* App-initiated actions should not see warning messages about the need to complete the action during login. */} - {displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && ( - } - type={ - message.type === "error" - ? "error" - : message.type === "success" - ? "success" - : message.type === "warning" - ? "warning" - : "info" - } - showIcon - style={{ marginBottom: "26px" }} - /> - )} -
- - {children} - {auth !== undefined && auth.showTryAnotherWayLink && ( -
- -
- -
-
- )} + + + {auth.attemptedUsername} + + {msg("restartLoginTooltip")} + + +
+ {isMobile && } + + )} + {/* App-initiated actions should not see warning messages about the need to complete the action during login. */} + {showMessage && ( + <> + } + type={ + message.type === "error" + ? "error" + : message.type === "success" + ? "success" + : message.type === "warning" + ? "warning" + : "info" + } + showIcon + style={{ margin: 0, width: "100%" }} + /> + {isMobile && } + + )} +
- {displayInfo &&
{infoNode}
} + + {children} + {isMobile && ( + + {client.name && {clientInfo}} + {auth !== undefined && auth.showTryAnotherWayLink && ( + {showTryAnotherWayLink} + )} + {displayInfo && {infoNode}} + + )} + + {!isMobile && ( + + {client.name && {clientInfo}} + {auth !== undefined && auth.showTryAnotherWayLink && ( + {showTryAnotherWayLink} + )} + {displayInfo && {infoNode}} + + )} + {!isMobile && ( © 2025 @@ -277,3 +312,27 @@ export default function Template(props: TemplateProps) { ); } + +function FooterCard({ children, darkMode = false }: { children?: ReactNode; darkMode?: boolean }) { + const isMobile = useMediaQuery({ maxWidth: 600 }); + return ( + + {children} + + ); +} diff --git a/src/login/UserProfileFormFields.tsx b/src/login/UserProfileFormFields.tsx index b7fcf39..0784bc0 100644 --- a/src/login/UserProfileFormFields.tsx +++ b/src/login/UserProfileFormFields.tsx @@ -11,7 +11,7 @@ import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFo import type { Attribute } from "keycloakify/login/KcContext"; import type { KcContext } from "./KcContext"; import type { I18n } from "./i18n"; -import { Form, FormItemProps, Input, InputProps, Typography, Select, Radio, Checkbox, Button, Space, Divider } from "antd"; +import { Form, FormItemProps, Input, InputProps, Typography, Select, Radio, Checkbox, Button, Space, Divider, Flex } from "antd"; import { EyeOutlined, EyeInvisibleOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons"; const { Text, Title } = Typography; @@ -36,68 +36,72 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps< const groupNameRef = { current: "" }; return ( - <> + {formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => { return ( <> - 0 ? "error" : undefined} - help={ - <> - {attribute.annotations.inputHelperTextBefore !== undefined && ( -
- {advancedMsg(attribute.annotations.inputHelperTextBefore)} -
- )} - - {attribute.annotations.inputHelperTextAfter !== undefined && ( -
- {advancedMsg(attribute.annotations.inputHelperTextAfter)} -
- )} - - } - rules={[{ required: true }]} - name={attribute.name} - shouldUpdate - key={attribute.name} - {...rest} - > - {BeforeField !== undefined && ( - - )} + + + {advancedMsg(attribute.displayName ?? "")} + * + + 0 ? "error" : undefined} + rules={[{ required: true }]} + name={attribute.name} + shouldUpdate + key={attribute.name} + {...rest} + > + {BeforeField !== undefined && ( + + )} - - {AfterField !== undefined && ( - - )} - + {AfterField !== undefined && ( + + )} +
+ + {attribute.annotations.inputHelperTextBefore !== undefined && ( +
+ {advancedMsg(attribute.annotations.inputHelperTextBefore)} +
+ )} + + {attribute.annotations.inputHelperTextAfter !== undefined && ( +
+ {advancedMsg(attribute.annotations.inputHelperTextAfter)} +
+ )} +
+
); })} - +
); } diff --git a/src/login/custom.css b/src/login/custom.css index 65ca444..d0293b0 100644 --- a/src/login/custom.css +++ b/src/login/custom.css @@ -40,8 +40,20 @@ h2 span { background: rgb(110, 0, 255); background: linear-gradient( 45deg, - rgba(110, 0, 255, 1) 0%, - rgba(212, 0, 255, 1) 100% + + rgb(255, 0, 153) 0%, + rgba(110, 0, 255) 100% + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.dark > h2, .dark > span { + background: rgb(110, 0, 255); + background: linear-gradient( + 45deg, + rgb(255, 0, 115) 0%, + rgb(170, 0, 255) 100% ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; @@ -57,8 +69,8 @@ a.ant-typography, background: rgb(110, 0, 255) !important; background: linear-gradient( 45deg, - rgba(110, 0, 255, 1) 0%, - rgba(212, 0, 255, 1) 100% + rgb(170, 0, 255) 0%, + rgb(255, 0, 115) 100% ) !important ; -webkit-background-clip: text !important; -webkit-text-fill-color: transparent !important; @@ -192,15 +204,36 @@ a.ant-typography, background: transparent !important; } -input:-webkit-autofill, -input:-webkit-autofill:hover, -input:-webkit-autofill:focus, -input:-webkit-autofill:active, -input:-internal-autofill-selected { - -webkit-background-clip: text; - -webkit-text-fill-color: #7e8500; - box-shadow: inset 0 0 20px 20px #ffffff !important; - background-color: green !important +/* Autofill fix for light mode */ +@media (prefers-color-scheme: light) { + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + input:-webkit-autofill:active, + input:-internal-autofill-selected, + input:-internal-autofill-previewed { + box-shadow: inset 0 0 20px 20px #ffffff00 !important; + -webkit-text-fill-color: #000 !important; /* black text in light mode */ + transition: background-color 5000s ease-in-out 0s; + } +} + +/* Autofill fix for dark mode */ +@media (prefers-color-scheme: dark) { + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + input:-webkit-autofill:active, + input:-internal-autofill-selected, + input:-internal-autofill-previewed { + box-shadow: inset 0 0 20px 20px #ffffff00 !important; + -webkit-text-fill-color: #fff !important; /* white text in dark mode */ + transition: background-color 5000s ease-in-out 0s; + } +} + +.ant-input-outlined:has(input:-webkit-autofill) { + border-color: rgb(250, 173, 20); } input[type="password"] { diff --git a/src/login/fonts.css b/src/login/fonts.css index 0d56412..22a9afe 100644 --- a/src/login/fonts.css +++ b/src/login/fonts.css @@ -247,6 +247,10 @@ format("opentype"); } +h2 { + line-height: 1 !important; +} + p, span, h1, diff --git a/src/login/pages/DeleteAccountConfirm.tsx b/src/login/pages/DeleteAccountConfirm.tsx index 07e936b..6381d30 100644 --- a/src/login/pages/DeleteAccountConfirm.tsx +++ b/src/login/pages/DeleteAccountConfirm.tsx @@ -66,14 +66,14 @@ export default function DeleteAccountConfirm(props: PageProps• {item} )} - style={{ margin: "16px 0" }} + style={{ margin: "18px 0" }} /> {msg("finalDeletionConfirmation")} - + )} -
+ ); } diff --git a/src/login/pages/FrontchannelLogout.stories.tsx b/src/login/pages/FrontchannelLogout.stories.tsx index 056ee25..4f1ecd2 100644 --- a/src/login/pages/FrontchannelLogout.stories.tsx +++ b/src/login/pages/FrontchannelLogout.stories.tsx @@ -13,7 +13,15 @@ export default meta; type Story = StoryObj; export const Default: Story = { - render: () => + render: () => ( + + ) }; export const WithoutRedirectUrl: Story = { render: () => ( diff --git a/src/login/pages/FrontchannelLogout.tsx b/src/login/pages/FrontchannelLogout.tsx index bc79244..6f3cbdc 100644 --- a/src/login/pages/FrontchannelLogout.tsx +++ b/src/login/pages/FrontchannelLogout.tsx @@ -1,10 +1,10 @@ import { useEffect } from "react"; -import { Button, List, Typography, Space } from "antd"; +import { Button, List, Typography, Flex } from "antd"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; -const { Paragraph } = Typography; +const { Text } = Typography; export default function FrontchannelLogout(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; @@ -26,8 +26,8 @@ export default function FrontchannelLogout(props: PageProps - - {msg("frontchannel-logout.message")} + + {msg("frontchannel-logout.message")} {logout.logoutRedirectUri && ( - )} - + ); } diff --git a/src/login/pages/Info.tsx b/src/login/pages/Info.tsx index 1778403..9123d20 100644 --- a/src/login/pages/Info.tsx +++ b/src/login/pages/Info.tsx @@ -1,4 +1,4 @@ -import { Typography, Button, Space } from "antd"; +import { Typography, Button, Flex } from "antd"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; @@ -29,7 +29,7 @@ export default function Info(props: PageProps + ); @@ -38,7 +38,7 @@ export default function Info(props: PageProps - @@ -48,7 +48,7 @@ export default function Info(props: PageProps - @@ -61,13 +61,12 @@ export default function Info(props: PageProps
- + - - - {renderActionLink()} + {renderActionLink()} +
); diff --git a/src/login/pages/Login.tsx b/src/login/pages/Login.tsx index 587427e..af2cc5f 100644 --- a/src/login/pages/Login.tsx +++ b/src/login/pages/Login.tsx @@ -70,12 +70,7 @@ export default function Login(props: PageProps - {msg("noAccount")} - {msg("doRegister")} - - } + infoNode={{msg("doRegister")}} socialProvidersNode={ <> {realm.password && social?.providers !== undefined && social.providers.length !== 0 && ( diff --git a/src/login/pages/LoginConfigTotp.stories.tsx b/src/login/pages/LoginConfigTotp.stories.tsx index 08ebfc1..9f526df 100644 --- a/src/login/pages/LoginConfigTotp.stories.tsx +++ b/src/login/pages/LoginConfigTotp.stories.tsx @@ -20,7 +20,16 @@ export const WithManualSetUp: Story = { render: () => ( ) diff --git a/src/login/pages/LoginConfigTotp.tsx b/src/login/pages/LoginConfigTotp.tsx index e03ef81..5114a3f 100644 --- a/src/login/pages/LoginConfigTotp.tsx +++ b/src/login/pages/LoginConfigTotp.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { getKcClsx, KcClsx } from "keycloakify/login/lib/kcClsx"; +// Removed unused kcClsx utilities import { kcSanitize } from "keycloakify/lib/kcSanitize"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; @@ -17,10 +17,7 @@ export default function LoginConfigTotp(props: PageProps{msg("loginTotpTitle")}} displayMessage={!messagesPerField.existsError("totp", "userLabel")} > - -
+ +
- - - {msg("authenticatorCode")} * - - } - name="totp" - validateStatus={messagesPerField.existsError("totp") ? "error" : undefined} - help={ - messagesPerField.existsError("totp") && ( - + + + + {msg("authenticatorCode")} * + + + + + + + + {messagesPerField.existsError("totp") && ( + + {kcSanitize(messagesPerField.get("totp"))} + + )} + + + + {msg("loginTotpDeviceName")} {totp.otpCredentials.length >= 1 && *} + + + + } + aria-invalid={messagesPerField.existsError("userLabel")} /> - ) - } - > - } - aria-invalid={messagesPerField.existsError("totp")} - /> - - - - {msg("loginTotpDeviceName")} {totp.otpCredentials.length >= 1 && *} - - } - name="userLabel" - validateStatus={messagesPerField.existsError("userLabel") ? "error" : undefined} - help={ - messagesPerField.existsError("userLabel") && ( - - ) - } - > - } - aria-invalid={messagesPerField.existsError("userLabel")} - /> - + + {messagesPerField.existsError("userLabel") && ( + + {kcSanitize(messagesPerField.get("userLabel"))} + + )} + +
- - + + + {msg("logoutOtherSessions")} + - + + + + ) : ( + - - - ) : ( - - )} -
- + )} +
+ +
); } - -function LogoutOtherSessions(props: { i18n: I18n }) { - const { i18n } = props; - const { msg } = i18n; - - return ( -
- - {msg("logoutOtherSessions")} - -
- ); -} diff --git a/src/login/pages/LoginRecoveryAuthnCodeInput.tsx b/src/login/pages/LoginRecoveryAuthnCodeInput.tsx index d497ee4..c80e246 100644 --- a/src/login/pages/LoginRecoveryAuthnCodeInput.tsx +++ b/src/login/pages/LoginRecoveryAuthnCodeInput.tsx @@ -32,6 +32,7 @@ export default function LoginRecoveryAuthnCodeInput(props: PageProps - diff --git a/src/login/pages/LoginResetOtp.tsx b/src/login/pages/LoginResetOtp.tsx index 59c9a7e..55681bf 100644 --- a/src/login/pages/LoginResetOtp.tsx +++ b/src/login/pages/LoginResetOtp.tsx @@ -1,4 +1,4 @@ -import { Form, Radio, Button, Typography, Space } from "antd"; +import { Form, Radio, Button, Typography, List, Flex } from "antd"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; @@ -20,40 +20,38 @@ export default function LoginResetOtp(props: PageProps
- - {msg("otp-reset-description")} - - - - {configuredOtpCredentials.userOtpCredentials.map((otpCredential, index) => ( - - - - {otpCredential.userLabel} - - - ))} - - - + + + {msg("otp-reset-description")} + + + ( + + + + {otpCredential.userLabel} + + + + + )} + /> + + + - +
); diff --git a/src/login/pages/LoginResetPassword.tsx b/src/login/pages/LoginResetPassword.tsx index 1373527..c669a55 100644 --- a/src/login/pages/LoginResetPassword.tsx +++ b/src/login/pages/LoginResetPassword.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Form, Input, Button, Alert, Space, Typography } from "antd"; +import { Form, Input, Button, Alert, Typography } from "antd"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; @@ -43,7 +43,8 @@ export default function LoginResetPassword(props: PageProps{msg("backToLogin")}} + displayInfo={true} displayMessage={!messagesPerField.existsError("username")} headerNode={msg("emailForgotTitle")} > @@ -57,7 +58,7 @@ export default function LoginResetPassword(props: PageProps - {displayInfo && displayMessage && } + {displayInfo && displayMessage && } - + - - - {msg("backToLogin")} - ); diff --git a/src/login/pages/LoginUpdatePassword.tsx b/src/login/pages/LoginUpdatePassword.tsx index 85e590b..39de5d0 100644 --- a/src/login/pages/LoginUpdatePassword.tsx +++ b/src/login/pages/LoginUpdatePassword.tsx @@ -1,10 +1,12 @@ import { useState } from "react"; -import { Button, Form, Input, Checkbox, Flex } from "antd"; +import { Button, Form, Input, Checkbox, Flex, Typography } from "antd"; import { EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; +const { Text } = Typography; + type FieldType = { "password-new": string; "password-confirm": string; @@ -57,51 +59,67 @@ export default function LoginUpdatePassword(props: PageProps
- - } - aria-invalid={messagesPerField.existsError("password", "password-confirm")} - iconRender={visible => (visible ? : )} - /> - + + + + {msg("passwordNew")} + * + + + } + aria-invalid={messagesPerField.existsError("password", "password-confirm")} + iconRender={visible => (visible ? : )} + /> + + {messagesPerField.existsError("password") ? {messagesPerField.get("password")} : null} + - - } - aria-invalid={messagesPerField.existsError("password", "password-confirm")} - iconRender={visible => (visible ? : )} - /> - - - - {msg("logoutOtherSessions")} - + + + {msg("passwordConfirm")} + * + + + } + aria-invalid={messagesPerField.existsError("password", "password-confirm")} + iconRender={visible => (visible ? : )} + /> + + {messagesPerField.existsError("password-confirm") ? ( + {messagesPerField.get("password-confirm")} + ) : null} + + + {msg("logoutOtherSessions")} + + - + + ) : ( + + )} + - )} - - - {recaptchaRequired && !recaptchaVisible && recaptchaAction !== undefined ? ( - - ) : ( - - )} - -
- {msg("backToLogin")} -
-
-
- -
+ + + ); } diff --git a/src/login/pages/SelectAuthenticator.tsx b/src/login/pages/SelectAuthenticator.tsx index 75b5c83..29f122e 100644 --- a/src/login/pages/SelectAuthenticator.tsx +++ b/src/login/pages/SelectAuthenticator.tsx @@ -1,4 +1,4 @@ -import { Typography, Button, List, Flex, Space } from "antd"; +import { Typography, Button, List, Flex } from "antd"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; @@ -7,7 +7,7 @@ const { Title, Text } = Typography; export default function SelectAuthenticator(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - const { url, auth, client } = kcContext; + const { url, auth } = kcContext; const { msg, advancedMsg } = i18n; return ( @@ -17,24 +17,9 @@ export default function SelectAuthenticator(props: PageProps - {client.attributes.logoUri ? ( - - {client.name - {msg("loginChooseAuthenticator")} - - ) : ( - {msg("loginChooseAuthenticator")} - )} - - } + headerNode={msg("loginChooseAuthenticator")} > -
+ (