From 5a4e90dfc0af3e6ef21ee03f4ac18ddbe3ae0caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Barto=C5=A1?= Date: Fri, 13 Feb 2026 16:34:42 +0100 Subject: [PATCH] Show login page for quick theme and change basic attributes (#45483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #45524 Signed-off-by: Martin Bartoš --- .../admin/messages/messages_en.properties | 6 +- js/apps/admin-ui/public/theme/login.css | 94 -------- .../themes/BackgroundContext.tsx | 23 ++ .../src/realm-settings/themes/ImageUpload.tsx | 10 +- .../themes/LoginPreviewWindow.tsx | 222 ++++++++++++++++++ .../themes/LoginThemeProperties.ts | 94 ++++++++ .../src/realm-settings/themes/ThemeColors.tsx | 59 ++++- .../src/realm-settings/themes/ThemesTab.tsx | 24 +- .../src/realm-settings/themes/fileUtils.ts | 8 + .../login/resources/css/styles.css | 1 + 10 files changed, 424 insertions(+), 117 deletions(-) delete mode 100644 js/apps/admin-ui/public/theme/login.css create mode 100644 js/apps/admin-ui/src/realm-settings/themes/BackgroundContext.tsx create mode 100644 js/apps/admin-ui/src/realm-settings/themes/LoginPreviewWindow.tsx create mode 100644 js/apps/admin-ui/src/realm-settings/themes/LoginThemeProperties.ts create mode 100644 js/apps/admin-ui/src/realm-settings/themes/fileUtils.ts diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 5bdab671606..65dd9322630 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -2955,6 +2955,8 @@ memberofLdapAttribute=Member-of LDAP attribute supportedLocales=Supported locales invalidLocale=Invalid locale selected showPasswordDataValue=Value +adminConsolePreview=Admin Console Preview +loginPagePreview=Login Page Preview webAuthnPolicyAttestationConveyancePreference=Attestation conveyance preference copyOf=Copy of {{name}} eventTypes.REMOVE_TOTP.description=Remove totp @@ -3262,6 +3264,8 @@ identityBrokeringLink=Identity brokering link searchClientRegistration=Search for policy importFileHelp=File to import a key logo=Logo +logoWidth=Logo width +logoHeight=Logo height avatarImage=Avatar image eventTypes.INVITE_ORG.name=Invite user to organization eventTypes.INVITE_ORG.description=Invite user to organization @@ -3751,4 +3755,4 @@ role_default-roles=Default roles role_impersonation=Impersonation role_read-token=Read token role_offline-access=Offline access -role_uma_authorization=Obtain permissions \ No newline at end of file +role_uma_authorization=Obtain permissions diff --git a/js/apps/admin-ui/public/theme/login.css b/js/apps/admin-ui/public/theme/login.css deleted file mode 100644 index ba03dbfea91..00000000000 --- a/js/apps/admin-ui/public/theme/login.css +++ /dev/null @@ -1,94 +0,0 @@ -.pf-v5-c-login__container { - grid-template-columns: 34rem; - grid-template-areas: "header" - "main" -} - -.login-pf body { - background: var(--keycloak-bg-logo-url) no-repeat center center fixed; - background-size: cover; - height: 100%; -} - -div.kc-logo-text { - background-image: var(--keycloak-logo-url); - height: var(--keycloak-logo-height); - width: var(--keycloak-logo-width); - background-repeat: no-repeat; - background-size: contain; - margin: 0 auto; -} - -div.kc-logo-text span { - display: none; -} - -.kc-login-tooltip { - position: relative; - display: inline-block; -} - -.kc-login-tooltip .kc-tooltip-text { - top: -3px; - left: 160%; - background-color: black; - visibility: hidden; - color: #fff; - - min-width: 130px; - text-align: center; - border-radius: 2px; - box-shadow: 0 1px 8px rgba(0, 0, 0, 0.6); - padding: 5px; - - position: absolute; - opacity: 0; - transition: opacity 0.5s; -} - -/* Show tooltip */ -.kc-login-tooltip:hover .kc-tooltip-text { - visibility: visible; - opacity: 0.7; -} - -/* Arrow for tooltip */ -.kc-login-tooltip .kc-tooltip-text::after { - content: " "; - position: absolute; - top: 15px; - right: 100%; - margin-top: -5px; - border-width: 5px; - border-style: solid; - border-color: transparent black transparent transparent; -} - -#kc-recovery-codes-list { - columns: 2; -} - -#certificate_subjectDN { - overflow-wrap: break-word -} - -#kc-header-wrapper { - font-size: 29px; - text-transform: uppercase; - letter-spacing: 3px; - line-height: 1.2em; - white-space: normal; - color: var(--pf-v5-global--Color--light-100) !important; - text-align: center; -} - -hr { - margin-top: var(--pf-v5-global--spacer--sm); - margin-bottom: var(--pf-v5-global--spacer--md); -} - -@media (min-width: 768px) { - div.pf-v5-c-login__main-header { - grid-template-columns: 70% 30%; - } -} \ No newline at end of file diff --git a/js/apps/admin-ui/src/realm-settings/themes/BackgroundContext.tsx b/js/apps/admin-ui/src/realm-settings/themes/BackgroundContext.tsx new file mode 100644 index 00000000000..4046742d8ea --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/BackgroundContext.tsx @@ -0,0 +1,23 @@ +import { createNamedContext } from "@keycloak/keycloak-ui-shared"; +import { PropsWithChildren, useContext, useState } from "react"; + +type BackgroundContextProps = { + background: string; + setBackground: (background: string) => void; +}; + +export const BackgroundPreviewContext = createNamedContext< + BackgroundContextProps | undefined +>("BackgroundContext", undefined); + +export const usePreviewBackground = () => useContext(BackgroundPreviewContext); + +export const BackgroundContext = ({ children }: PropsWithChildren) => { + const [background, setBackground] = useState(""); + + return ( + + {children} + + ); +}; diff --git a/js/apps/admin-ui/src/realm-settings/themes/ImageUpload.tsx b/js/apps/admin-ui/src/realm-settings/themes/ImageUpload.tsx index c47c4b31eff..a47cf44bfc9 100644 --- a/js/apps/admin-ui/src/realm-settings/themes/ImageUpload.tsx +++ b/js/apps/admin-ui/src/realm-settings/themes/ImageUpload.tsx @@ -2,6 +2,7 @@ import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared"; import { FileUpload } from "@patternfly/react-core"; import { useEffect, useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; +import { fileToDataUri } from "./fileUtils"; type ImageUploadProps = { name: string; @@ -15,15 +16,6 @@ export const ImageUpload = ({ name, onChange }: ImageUploadProps) => { const { control, watch } = useFormContext(); - const fileToDataUri = (file: File) => - new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = (event) => { - resolve(event.target?.result as string); - }; - reader.readAsDataURL(file); - }); - if (file) { void fileToDataUri(file).then((dataUri) => { setDataUri(dataUri); diff --git a/js/apps/admin-ui/src/realm-settings/themes/LoginPreviewWindow.tsx b/js/apps/admin-ui/src/realm-settings/themes/LoginPreviewWindow.tsx new file mode 100644 index 00000000000..60f565aa51b --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/LoginPreviewWindow.tsx @@ -0,0 +1,222 @@ +import { loginThemeProperties as properties } from "./LoginThemeProperties"; +import { usePreviewLogo } from "./LogoContext"; +import { useEnvironment } from "@keycloak/keycloak-ui-shared"; +import { Environment } from "../../environment"; +import { usePreviewBackground } from "./BackgroundContext"; + +type LoginPreviewWindowProps = { + cssVars: Record; +}; + +export const LoginPreviewWindow = ({ cssVars }: LoginPreviewWindowProps) => { + const { environment } = useEnvironment(); + const contextLogo = usePreviewLogo(); + const contextBackground = usePreviewBackground(); + + // Resources + const resourceUrlRoot = `/resources/${environment.resourceVersion}`; + const loginResourceUrl = `${resourceUrlRoot}/login/keycloak.v2`; + + // Default login theme resources from local files + const defaultBgImage = `${loginResourceUrl}/img/keycloak-bg-darken.svg`; + const defaultLogo = `${loginResourceUrl}/img/keycloak-logo-text.svg`; + + // Use uploaded images or fall back to local defaults + // Both logo and background come from context for immediate reactivity + const logoUrl = contextLogo?.logo || defaultLogo; + const bgUrl = contextBackground?.background || defaultBgImage; + + const logoWidth = cssVars["logoWidth"]; + const logoHeight = cssVars["logoHeight"]; + + // CSS files + const pf5CssRootPath = `${resourceUrlRoot}/common/keycloak/vendor/patternfly-v5`; + const pf5Css = `${pf5CssRootPath}/patternfly.min.css`; + const pf5AddonsCss = `${pf5CssRootPath}/patternfly-addons.css`; + + const stylesThemeCssUrl = `${loginResourceUrl}/css/styles.css`; + + return ( + <> + + + + + + +
+
+
+
+
+
+
+
+ Keycloak +
+
+
+
+
+

+ Sign in to your account +

+
+
+
+
+
e.preventDefault()} + noValidate + > + {/* Username field */} +
+
+ +
+ + + +
+
+ + {/* Password field */} +
+
+ +
+
+
+ + + +
+
+ +
+
+
+
+
+
+
+ + {/* Submit button */} +
+
+ +
+
+
+
+
+ +
+ {/* Social providers or additional info would go here */} +
+
+ +
+
+
+
+
+
+
+ + ); +}; diff --git a/js/apps/admin-ui/src/realm-settings/themes/LoginThemeProperties.ts b/js/apps/admin-ui/src/realm-settings/themes/LoginThemeProperties.ts new file mode 100644 index 00000000000..e68b803b995 --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/LoginThemeProperties.ts @@ -0,0 +1,94 @@ +// Keycloak v2 Login Theme Properties +// Based on theme/keycloak.v2/login/theme.properties + +export const loginThemeProperties = { + // Form classes + kcFormGroupClass: "pf-v5-c-form__group", + kcFormGroupLabelClass: "pf-v5-c-form__group-label pf-v5-u-pb-xs", + kcFormLabelClass: "pf-v5-c-form__label", + kcFormLabelTextClass: "pf-v5-c-form__label-text", + kcLabelClass: "pf-v5-c-form__label", + kcInputClass: "pf-v5-c-form-control", + kcInputGroup: "pf-v5-c-input-group", + kcFormHelperTextClass: "pf-v5-c-form__helper-text", + kcInputHelperTextClass: + "pf-v5-c-helper-text pf-v5-u-display-flex pf-v5-u-justify-content-space-between", + kcInputHelperTextItemClass: "pf-v5-c-helper-text__item", + kcInputHelperTextItemTextClass: "pf-v5-c-helper-text__item-text", + kcInputGroupItemClass: "pf-v5-c-input-group__item", + kcFill: "pf-m-fill", + kcError: "pf-m-error", + + // Checkbox classes + kcCheckboxClass: "pf-v5-c-check", + kcCheckboxInputClass: "pf-v5-c-check__input", + kcCheckboxLabelClass: "pf-v5-c-check__label", + kcCheckboxLabelRequiredClass: "pf-v5-c-check__label-required", + + // Form control utilities + kcInputRequiredClass: "pf-v5-c-form__label-required", + kcInputErrorMessageClass: + "pf-v5-c-helper-text__item-text pf-m-error kc-feedback-text", + kcFormControlUtilClass: "pf-v5-c-form-control__utilities", + kcInputErrorIconStatusClass: "pf-v5-c-form-control__icon pf-m-status", + kcInputErrorIconClass: "fas fa-exclamation-circle", + + // Alert classes + kcAlertClass: "pf-v5-c-alert pf-m-inline pf-v5-u-mb-md", + kcAlertIconClass: "pf-v5-c-alert__icon", + kcAlertTitleClass: "pf-v5-c-alert__title", + kcAlertDescriptionClass: "pf-v5-c-alert__description", + + // Password visibility + kcFormPasswordVisibilityButtonClass: "pf-v5-c-button pf-m-control", + kcFormPasswordVisibilityIconShow: "fa-eye fas", + kcFormPasswordVisibilityIconHide: "fa-eye-slash fas", + kcFormControlToggleIcon: "pf-v5-c-form-control__toggle-icon", + + // Form actions + kcFormActionGroupClass: "pf-v5-c-form__actions pf-v5-u-pt-xs", + kcFormReadOnlyClass: "pf-m-readonly", + + // Button classes + kcButtonClass: "pf-v5-c-button", + kcButtonPrimaryClass: "pf-v5-c-button pf-m-primary", + kcButtonSecondaryClass: "pf-v5-c-button pf-m-secondary", + kcButtonBlockClass: "pf-m-block", + kcButtonLinkClass: "pf-v5-c-button pf-m-link", + + // Login layout classes + kcLogin: "pf-v5-c-login", + kcLoginContainer: "pf-v5-c-login__container", + kcLoginMain: "pf-v5-c-login__main", + kcLoginMainHeader: "pf-v5-c-login__main-header", + kcLoginMainFooter: "pf-v5-c-login__main-footer", + kcLoginMainFooterBand: "pf-v5-c-login__main-footer-band", + kcLoginMainFooterBandItem: "pf-v5-c-login__main-footer-band-item", + kcLoginMainFooterHelperText: "pf-v5-u-font-size-sm pf-v5-u-color-200", + kcLoginMainTitle: "pf-v5-c-title pf-m-3xl", + kcLoginMainHeaderUtilities: "pf-v5-c-login__main-header-utilities", + kcLoginMainBody: "pf-v5-c-login__main-body", + + // Form and card classes + kcLoginClass: "pf-v5-c-login__main", + kcFormClass: "pf-v5-c-form pf-v5-u-w-100", + kcFormCardClass: "card-pf", + + // Feedback icons + kcFeedbackErrorIcon: "fa fa-fw fa-exclamation-circle", + kcFeedbackWarningIcon: "fa fa-fw fa-exclamation-triangle", + kcFeedbackSuccessIcon: "fa fa-fw fa-check-circle", + kcFeedbackInfoIcon: "fa fa-fw fa-info-circle", + + // Dark mode + kcDarkModeClass: "pf-v5-theme-dark", + + // HTML and Body classes + kcHtmlClass: "login-pf", + kcBodyClass: "", + + // Content wrapper + kcContentWrapperClass: "pf-v5-u-mb-md-on-md", +}; + +export type LoginThemeProperties = typeof loginThemeProperties; diff --git a/js/apps/admin-ui/src/realm-settings/themes/ThemeColors.tsx b/js/apps/admin-ui/src/realm-settings/themes/ThemeColors.tsx index d20827b314d..7fda29236f2 100644 --- a/js/apps/admin-ui/src/realm-settings/themes/ThemeColors.tsx +++ b/js/apps/admin-ui/src/realm-settings/themes/ThemeColors.tsx @@ -8,6 +8,8 @@ import { InputGroup, InputGroupItem, PageSection, + Tab, + Tabs, Text, TextContent, TextInputProps, @@ -25,9 +27,12 @@ import { useTranslation } from "react-i18next"; import { FixedButtonsGroup } from "../../components/form/FixedButtonGroup"; import { FormAccess } from "../../components/form/FormAccess"; import useToggle from "../../utils/useToggle"; +import { fileToDataUri } from "./fileUtils"; import { FileNameDialog } from "./FileNameDialog"; import { ImageUpload } from "./ImageUpload"; +import { LoginPreviewWindow } from "./LoginPreviewWindow"; import { usePreviewLogo } from "./LogoContext"; +import { usePreviewBackground } from "./BackgroundContext"; import { darkTheme, lightTheme } from "./PatternflyVars"; import { PreviewWindow } from "./PreviewWindow"; import { ThemeRealmRepresentation } from "./ThemesTab"; @@ -89,7 +94,9 @@ export const ThemeColors = ({ const { handleSubmit, watch } = form; const style = watch(); const contextLogo = usePreviewLogo(); + const contextBackground = usePreviewBackground(); const [open, toggle, setOpen] = useToggle(); + const [previewTab, setPreviewTab] = useState(0); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const getDarkModeFromRealm = () => { @@ -121,11 +128,23 @@ export const ThemeColors = ({ reset(); }; - const upload = (values: ThemeRealmRepresentation) => { + const upload = async (values: ThemeRealmRepresentation) => { form.setValue("bgimage", values.bgimage); form.setValue("favicon", values.favicon); form.setValue("logo", values.logo); + form.setValue("logoWidth", values.logoWidth); + form.setValue("logoHeight", values.logoHeight); form.reset(values); + + // Update contexts with data URIs so preview reflects uploaded images + if (values.logo) { + const logoDataUri = await fileToDataUri(values.logo); + contextLogo?.setLogo(logoDataUri); + } + if (values.bgimage) { + const bgDataUri = await fileToDataUri(values.bgimage); + contextBackground?.setBackground(bgDataUri); + } }; const convert = (values: Record) => { @@ -134,6 +153,8 @@ export const ThemeColors = ({ ...realm, favicon: values.favicon as File, logo: values.logo as File, + logoWidth: values.logoWidth as string, + logoHeight: values.logoHeight as string, bgimage: values.bgimage as File, fileName: values.name as string, attributes: { @@ -201,8 +222,23 @@ export const ThemeColors = ({ onChange={(logo) => contextLogo?.setLogo(logo)} /> + + - + contextBackground?.setBackground(bg)} + /> {mapping.map((m) => ( - + setPreviewTab(index as number)} + > + + + + + + + (); const isFeatureEnabled = useIsFeatureEnabled(); const saveTheme = async (realm: ThemeRealmRepresentation) => { @@ -84,7 +86,7 @@ styles=css/theme-styles.css parent=keycloak.v2 import=common/quick-theme -styles=css/login.css css/theme-styles.css +styles=css/styles.css css/theme-styles.css `, ); @@ -117,18 +119,20 @@ styles=css/login.css css/theme-styles.css .map(([key, value]) => `--pf-v5-global--${key}: ${value};`) .join("\n"); - const logoCss = ( - await fetch(joinPath(environment.resourceUrl, "/theme/login.css")) + const loginCss = ( + await fetch( + `/resources/${environment.resourceVersion}/login/keycloak.v2/css/styles.css`, + ) ).text(); - zip.file("theme/quick-theme/common/resources/css/login.css", logoCss); + zip.file("theme/quick-theme/common/resources/css/styles.css", loginCss); zip.file( "theme/quick-theme/common/resources/css/theme-styles.css", `:root { - --keycloak-bg-logo-url: url('../${bgimageName}'); - --keycloak-logo-url: url('../${logoName}'); - --keycloak-logo-height: 63px; - --keycloak-logo-width: 300px; + ${bgimage ? `--keycloak-bg-logo-url: url('../${bgimageName}');` : ""} + ${logo ? `--keycloak-logo-url: url('../${logoName}');` : ""} + --keycloak-logo-height: ${realm.logoHeight}; + --keycloak-logo-width: ${realm.logoWidth}; ${toCss(styles.light)} } .pf-v5-theme-dark { diff --git a/js/apps/admin-ui/src/realm-settings/themes/fileUtils.ts b/js/apps/admin-ui/src/realm-settings/themes/fileUtils.ts new file mode 100644 index 00000000000..6cd3de0735c --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/fileUtils.ts @@ -0,0 +1,8 @@ +export const fileToDataUri = (file: File) => + new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (event) => { + resolve(event.target?.result as string); + }; + reader.readAsDataURL(file); + }); diff --git a/themes/src/main/resources/theme/keycloak.v2/login/resources/css/styles.css b/themes/src/main/resources/theme/keycloak.v2/login/resources/css/styles.css index a4503cc349f..cd19326865a 100644 --- a/themes/src/main/resources/theme/keycloak.v2/login/resources/css/styles.css +++ b/themes/src/main/resources/theme/keycloak.v2/login/resources/css/styles.css @@ -38,6 +38,7 @@ div.kc-logo-text { width: var(--keycloak-logo-width); background-repeat: no-repeat; background-size: contain; + background-position: center; margin: 0 auto; }