Show login page for quick theme and change basic attributes (#45483)

Closes #45524

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
This commit is contained in:
Martin Bartoš 2026-02-13 16:34:42 +01:00 committed by GitHub
parent c17d9d0d0c
commit 5a4e90dfc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 424 additions and 117 deletions

View file

@ -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
role_uma_authorization=Obtain permissions

View file

@ -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%;
}
}

View file

@ -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 (
<BackgroundPreviewContext.Provider value={{ background, setBackground }}>
{children}
</BackgroundPreviewContext.Provider>
);
};

View file

@ -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<string>((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);

View file

@ -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<string, string>;
};
export const LoginPreviewWindow = ({ cssVars }: LoginPreviewWindowProps) => {
const { environment } = useEnvironment<Environment>();
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 (
<>
<link rel="stylesheet" href={pf5Css} />
<link rel="stylesheet" href={pf5AddonsCss} />
<link rel="stylesheet" href={stylesThemeCssUrl} />
<style>{`
.login-preview {
${Object.entries(cssVars)
.map(([key, value]) => `--pf-v5-global--${key}: ${value};`)
.join("\n")}
/* Keycloak login theme variables - override with local/uploaded images */
--keycloak-logo-url: url('${logoUrl}');
--keycloak-bg-logo-url: url('${bgUrl}');
${logoHeight ? `--keycloak-logo-height: ${logoHeight};` : ""}
${logoWidth ? `--keycloak-logo-width: ${logoWidth};` : ""}
}
/* Apply background to #keycloak-bg */
.login-preview.${properties.kcHtmlClass} {
background: var(--keycloak-bg-logo-url) no-repeat center center;
background-size: cover;
}
/* Ensure login container is properly sized */
.login-preview .${properties.kcLogin} {
min-height:70vh;
}
/* Force single column layout */
.login-preview .${properties.kcLoginContainer} {
grid-template-columns: 34rem !important;
grid-template-areas: "header"
"main" !important;
}
`}</style>
<div className="login-preview-wrapper">
<div className={`login-preview ${properties.kcHtmlClass}`}>
<div id="keycloak-bg" data-page-id="login-preview">
<div className={properties.kcLogin}>
<div className={properties.kcLoginContainer}>
<header id="kc-header" className="pf-v5-c-login__header">
<div id="kc-header-wrapper" className="pf-v5-c-brand">
<div className="kc-logo-text">
<span>Keycloak</span>
</div>
</div>
</header>
<main className={properties.kcLoginMain}>
<div className={properties.kcLoginMainHeader}>
<h1
className={properties.kcLoginMainTitle}
id="kc-page-title"
>
Sign in to your account
</h1>
</div>
<div className={properties.kcLoginMainBody}>
<div id="kc-form">
<div id="kc-form-wrapper">
<form
id="kc-form-login"
className={properties.kcFormClass}
onSubmit={(e) => e.preventDefault()}
noValidate
>
{/* Username field */}
<div className={properties.kcFormGroupClass}>
<div className={properties.kcFormGroupLabelClass}>
<label
htmlFor="username"
className={properties.kcFormLabelClass}
>
<span
className={properties.kcFormLabelTextClass}
>
Username or email
</span>
</label>
</div>
<span className={properties.kcInputClass}>
<input
id="username"
name="username"
value=""
type="text"
autoComplete="username"
readOnly
/>
</span>
<div id="input-error-container-username"></div>
</div>
{/* Password field */}
<div className={properties.kcFormGroupClass}>
<div className={properties.kcFormGroupLabelClass}>
<label
htmlFor="password"
className={properties.kcFormLabelClass}
>
<span
className={properties.kcFormLabelTextClass}
>
Password
</span>
</label>
</div>
<div className={properties.kcInputGroup}>
<div
className={`${properties.kcInputGroupItemClass} ${properties.kcFill}`}
>
<span className={properties.kcInputClass}>
<input
id="password"
name="password"
value=""
type={"password"}
autoComplete="current-password"
readOnly
/>
</span>
</div>
<div className={properties.kcInputGroupItemClass}>
<button
className={
properties.kcFormPasswordVisibilityButtonClass
}
type="button"
aria-label={"Show password"}
>
<i
className={
properties.kcFormPasswordVisibilityIconShow
}
aria-hidden="true"
></i>
</button>
</div>
</div>
<div
className={properties.kcFormHelperTextClass}
aria-live="polite"
>
<div
className={properties.kcInputHelperTextClass}
></div>
</div>
<div id="input-error-container-password"></div>
</div>
{/* Submit button */}
<div className={properties.kcFormGroupClass}>
<div className={properties.kcFormActionGroupClass}>
<button
className={`${properties.kcButtonPrimaryClass} ${properties.kcButtonBlockClass}`}
name="login"
id="kc-login"
type="submit"
>
Sign In
</button>
</div>
</div>
</form>
</div>
</div>
<div className={properties.kcLoginMainFooter}>
{/* Social providers or additional info would go here */}
</div>
</div>
<div className={properties.kcLoginMainFooter}></div>
</main>
</div>
</div>
</div>
</div>
</div>
</>
);
};

View file

@ -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;

View file

@ -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<string, File | string>) => {
@ -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)}
/>
</FormGroup>
<TextControl
name={"logoWidth"}
label={t("logoWidth")}
placeholder="300px"
defaultValue="300px"
/>
<TextControl
name={"logoHeight"}
label={t("logoHeight")}
placeholder="63px"
defaultValue="63px"
/>
<FormGroup label={t("backgroundImage")}>
<ImageUpload name="bgimage" />
<ImageUpload
name="bgimage"
onChange={(bg) => contextBackground?.setBackground(bg)}
/>
</FormGroup>
{mapping.map((m) => (
<ColorControl
@ -216,7 +252,24 @@ export const ThemeColors = ({
</FormAccess>
</FlexItem>
<FlexItem grow={{ default: "grow" }} style={{ zIndex: 0 }}>
<PreviewWindow cssVars={style?.[theme] || {}} />
<Tabs
activeKey={previewTab}
isBox
onSelect={(_, index) => setPreviewTab(index as number)}
>
<Tab title={t("loginPagePreview")} eventKey={0}>
<LoginPreviewWindow
cssVars={{
...(style?.[theme] || {}),
logoWidth: style?.["logoWidth"],
logoHeight: style?.["logoHeight"],
}}
/>
</Tab>
<Tab title={t("adminConsolePreview")} eventKey={1}>
<PreviewWindow cssVars={style?.[theme] || {}} />
</Tab>
</Tabs>
</FlexItem>
</Flex>
<FixedButtonsGroup

View file

@ -1,5 +1,6 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { useEnvironment } from "@keycloak/keycloak-ui-shared";
import { Environment } from "../../environment";
import { Tab, TabTitleText } from "@patternfly/react-core";
import JSZip from "jszip";
import { useTranslation } from "react-i18next";
@ -8,7 +9,6 @@ import {
useRoutableTab,
} from "../../components/routable-tabs/RoutableTabs";
import { useRealm } from "../../context/realm-context/RealmContext";
import { joinPath } from "../../utils/joinPath";
import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";
import { ThemesTabType, toThemesTab } from "../routes/ThemesTab";
import { LogoContext } from "./LogoContext";
@ -24,13 +24,15 @@ export type ThemeRealmRepresentation = RealmRepresentation & {
fileName?: string;
favicon?: File;
logo?: File;
logoWidth?: string;
logoHeight?: string;
bgimage?: File;
};
export default function ThemesTab({ realm, save }: ThemesTabProps) {
const { t } = useTranslation();
const { realm: realmName } = useRealm();
const { environment } = useEnvironment();
const { environment } = useEnvironment<Environment>();
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 {

View file

@ -0,0 +1,8 @@
export const fileToDataUri = (file: File) =>
new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target?.result as string);
};
reader.readAsDataURL(file);
});

View file

@ -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;
}