color theme tab (#35179)

* added a way to customize theme colors

fixes: #33233
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added preview and grouped vars

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added dark mode

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* fixed label

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added empty check

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* use json string in attributes

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* removed use of not exported type

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* output css based on JSON string

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added feature flag

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added a way to customize theme colors

fixes: #33233
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* renamed feature to quick theme

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* fixed merge error

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* Restore the Cache tab in Realm Settings (#34311)

closes keycloak#17727

Signed-off-by: Christian Janker <christian.janker@gmx.at>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added a way to customize theme colors

fixes: #33233
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* create a zip file instead

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added themes.json to make jar usable

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* use property instead of attribute

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* fix the jar file

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* fixed header for preview and some text

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

---------

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Christian Janker <christian.janker@gmx.at>
Co-authored-by: Christian Ja <christian.janker@gmx.at>
This commit is contained in:
Erik Jan de Wit
2024-12-04 20:36:42 +01:00
committed by GitHub
parent a1f5234571
commit 566e41cc72
20 changed files with 879 additions and 37 deletions

View File

@@ -65,6 +65,8 @@ public class Profile {
LOGIN_V1("Legacy Login Theme", Type.DEPRECATED, 1),
QUICK_THEME("WYSIWYG theme configuration tool", Type.EXPERIMENTAL, 1),
DOCKER("Docker Registry protocol", Type.DISABLED_BY_DEFAULT),
IMPERSONATION("Ability for admins to impersonate users", Type.DEFAULT),

View File

@@ -51,7 +51,9 @@ export const Header = () => {
features={{ hasManageAccount: false }}
brand={{
href: indexHref,
src: joinPath(environment.resourceUrl, brandImage),
src: brandImage.startsWith("/")
? joinPath(environment.resourceUrl, brandImage)
: brandImage,
alt: t("logo"),
className: style.brand,
}}

View File

@@ -1344,6 +1344,30 @@ keystorePasswordHelp=Password for the keys
clientSettings=Client details
deleteClientPolicyConditionConfirm=This action will permanently delete {{condition}}. This cannot be undone.
selectATheme=Select a theme
themeColors=Theme colors
defaults=Reset to defaults
themeColorsLight=Theme colors [light]
themeColorsDark=Theme colors [dark]
themePreviewInfo=In order to preview the theme colors, the current themen needs to be set to the one you want to preview, so we have automatically switched you to the one you want to preview.
backgroundImage=Login background image
errorColor=Error color
successColor=Success color
activeColor=Active color
primaryColor=Primary color
primaryColorHover=Primary color hover
secondaryColor=Secondary color
linkColor=Link color
linkColorHover=Link color hover
backgroundColorAccent=Background color accent
backgroundColor=Background color
backgroundColorNav=Background color navigation
backgroundColorHeader=Background color header
iconColor=Icon color
textColor=Text color
lightTextColor=Light text color
inputBackgroundColor=Input background color
inputTextColor=Input text color
defaults=Reset to defaults
permissionsList=Permission list
attributeGroupHelp=Specifies the user profile group where this attribute will be added. This allows grouping various similar attributes together on different parts of the screen when creating or updating user.
createRealm=Create realm
@@ -3306,4 +3330,6 @@ organizationsMembersListError=Could not fetch organization members\: {{error}}
MANAGED=Managed
UNMANAGED=Unmanaged
deleteConfirmUsers_one=Delete user {{name}}?
deleteConfirmUsers_other=Delete {{count}} users?
deleteConfirmUsers_other=Delete {{count}} users?
downloadThemeJar=Download theme JAR
themeColorInfo=Here you can set the patternfly color variables and create a "theme jar" file that you can download and put in your providers folder to apply the theme to your realm.

View File

@@ -102,6 +102,7 @@
"flat": "^6.0.1",
"i18next": "^24.0.2",
"i18next-http-backend": "^3.0.1",
"jszip": "^3.10.1",
"keycloak-js": "workspace:*",
"lodash-es": "^4.17.21",
"monaco-editor": "^0.52.0",

View File

@@ -0,0 +1,94 @@
.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

@@ -1,3 +1,4 @@
import { useEnvironment, useHelp } from "@keycloak/keycloak-ui-shared";
import {
Avatar,
Divider,
@@ -18,14 +19,15 @@ import { BarsIcon, EllipsisVIcon, HelpIcon } from "@patternfly/react-icons";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useHref } from "react-router-dom";
import { useEnvironment, useHelp } from "@keycloak/keycloak-ui-shared";
import { PageHeaderClearCachesModal } from "./PageHeaderClearCachesModal";
import { HelpHeader } from "./components/help-enabler/HelpHeader";
import { useAccess } from "./context/access/Access";
import { useRealm } from "./context/realm-context/RealmContext";
import { useWhoAmI } from "./context/whoami/WhoAmI";
import { toDashboard } from "./dashboard/routes/Dashboard";
import { usePreviewLogo } from "./realm-settings/themes/LogoContext";
import { joinPath } from "./utils/joinPath";
import useToggle from "./utils/useToggle";
import { PageHeaderClearCachesModal } from "./PageHeaderClearCachesModal";
import { useAccess } from "./context/access/Access";
const ManageAccountDropdownItem = () => {
const { keycloak } = useEnvironment();
@@ -184,9 +186,11 @@ export const Header = () => {
const { environment, keycloak } = useEnvironment();
const { t } = useTranslation();
const { realm } = useRealm();
const contextLogo = usePreviewLogo();
const customLogo = contextLogo?.logo;
const picture = keycloak.tokenParsed?.picture;
const logo = environment.logo ? environment.logo : "/logo.svg";
const logo = customLogo || environment.logo || "/logo.svg";
const url = useHref(toDashboard({ realm }));
const logoUrl = environment.logoUrl ? environment.logoUrl : url;
@@ -199,7 +203,11 @@ export const Header = () => {
</MastheadToggle>
<MastheadBrand href={logoUrl}>
<img
src={environment.resourceUrl + logo}
src={
logo.startsWith("/")
? joinPath(environment.resourceUrl, logo)
: logo
}
id="masthead-logo"
alt={t("logo")}
aria-label={t("logo")}

View File

@@ -253,7 +253,9 @@ export { PoliciesTab } from "./realm-settings/PoliciesTab";
export * as RealmSettingsSection from "./realm-settings/RealmSettingsSection";
export { RealmSettingsTabs } from "./realm-settings/RealmSettingsTabs";
export { RealmSettingsSessionsTab } from "./realm-settings/SessionsTab";
export { RealmSettingsThemesTab } from "./realm-settings/ThemesTab";
export * as ThemesTab from "./realm-settings/themes/ThemesTab";
export { ThemeColors } from "./realm-settings/themes/ThemeColors";
export { ThemeSettingsTab } from "./realm-settings/themes/ThemeSettings";
export { RealmSettingsTokensTab } from "./realm-settings/TokensTab";
export { UserRegistration } from "./realm-settings/UserRegistration";
export { RevocationModal } from "./sessions/RevocationModal";

View File

@@ -42,7 +42,7 @@ import { PartialImportDialog } from "./PartialImport";
import { PoliciesTab } from "./PoliciesTab";
import ProfilesTab from "./ProfilesTab";
import { RealmSettingsSessionsTab } from "./SessionsTab";
import { RealmSettingsThemesTab } from "./ThemesTab";
import ThemesTab from "./themes/ThemesTab";
import { RealmSettingsTokensTab } from "./TokensTab";
import { UserRegistration } from "./UserRegistration";
import { EventsTab } from "./event-config/EventsTab";
@@ -355,7 +355,7 @@ export const RealmSettingsTabs = () => {
data-testid="rs-themes-tab"
{...themesTab}
>
<RealmSettingsThemesTab realm={realm!} save={save} />
<ThemesTab realm={realm!} save={save} />
</Tab>
<Tab
title={<TabTitleText>{t("keys")}</TabTitleText>}

View File

@@ -1,24 +1,25 @@
import type { AppRouteObject } from "../routes";
import { AddAttributeRoute } from "./routes/AddAttribute";
import { AddClientPolicyRoute } from "./routes/AddClientPolicy";
import { AddClientProfileRoute } from "./routes/AddClientProfile";
import { NewClientPolicyConditionRoute } from "./routes/AddCondition";
import { AddExecutorRoute } from "./routes/AddExecutor";
import { AttributeRoute } from "./routes/Attribute";
import { ClientPoliciesRoute } from "./routes/ClientPolicies";
import { ClientProfileRoute } from "./routes/ClientProfile";
import { EditAttributesGroupRoute } from "./routes/EditAttributesGroup";
import { EditClientPolicyRoute } from "./routes/EditClientPolicy";
import { EditClientPolicyConditionRoute } from "./routes/EditCondition";
import { ExecutorRoute } from "./routes/Executor";
import { KeyProviderFormRoute } from "./routes/KeyProvider";
import { KeysRoute } from "./routes/KeysTab";
import { NewAttributesGroupRoute } from "./routes/NewAttributesGroup";
import {
RealmSettingsRoute,
RealmSettingsRouteWithTab,
} from "./routes/RealmSettings";
import { ClientPoliciesRoute } from "./routes/ClientPolicies";
import { AddClientProfileRoute } from "./routes/AddClientProfile";
import { ClientProfileRoute } from "./routes/ClientProfile";
import { AddExecutorRoute } from "./routes/AddExecutor";
import { ExecutorRoute } from "./routes/Executor";
import { AddClientPolicyRoute } from "./routes/AddClientPolicy";
import { EditClientPolicyRoute } from "./routes/EditClientPolicy";
import { NewClientPolicyConditionRoute } from "./routes/AddCondition";
import { EditClientPolicyConditionRoute } from "./routes/EditCondition";
import { ThemeTabRoute } from "./routes/ThemesTab";
import { UserProfileRoute } from "./routes/UserProfile";
import { AddAttributeRoute } from "./routes/AddAttribute";
import { KeysRoute } from "./routes/KeysTab";
import { AttributeRoute } from "./routes/Attribute";
import { NewAttributesGroupRoute } from "./routes/NewAttributesGroup";
import { EditAttributesGroupRoute } from "./routes/EditAttributesGroup";
const routes: AppRouteObject[] = [
RealmSettingsRoute,
@@ -39,6 +40,7 @@ const routes: AppRouteObject[] = [
AttributeRoute,
NewAttributesGroupRoute,
EditAttributesGroupRoute,
ThemeTabRoute,
];
export default routes;

View File

@@ -0,0 +1,26 @@
import { lazy } from "react";
import type { Path } from "react-router-dom";
import { generateEncodedPath } from "../../utils/generateEncodedPath";
import type { AppRouteObject } from "../../routes";
export type ThemesTabType = "settings" | "lightColors" | "darkColors";
export type ThemesParams = {
realm: string;
tab: ThemesTabType;
};
const RealmSettingsSection = lazy(() => import("../RealmSettingsSection"));
export const ThemeTabRoute: AppRouteObject = {
path: "/:realm/realm-settings/themes/:tab",
element: <RealmSettingsSection />,
breadcrumb: (t) => t("themes"),
handle: {
access: "view-realm",
},
};
export const toThemesTab = (params: ThemesParams): Partial<Path> => ({
pathname: generateEncodedPath(ThemeTabRoute.path, params),
});

View File

@@ -0,0 +1,69 @@
import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared";
import { FileUpload } from "@patternfly/react-core";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
type ImageUploadProps = {
name: string;
onChange?: (file: string) => void;
};
export const ImageUpload = ({ name, onChange }: ImageUploadProps) => {
const [dataUri, setDataUri] = useState("");
const [file, setFile] = useState<File>();
const [isLoading, setIsLoading] = useState(false);
const { control } = 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) {
fileToDataUri(file).then((dataUri) => {
setDataUri(dataUri);
onChange?.(dataUri);
});
}
return (
<Controller
name={name}
control={control}
defaultValue=""
render={({ field }) => (
<>
{isLoading && <KeycloakSpinner />}
{dataUri && <img src={dataUri} width={200} height={200} />}
<FileUpload
id={name}
type="dataURL"
filename={file?.name}
dropzoneProps={{
accept: {
"image/*": [".png", ".gif", ".jpeg", ".jpg", ".svg", ".webp"],
},
}}
onFileInputChange={(_, file) => setFile(file)}
onReadStarted={() => setIsLoading(true)}
onReadFinished={(_, file) => {
setFile(file);
field.onChange(file);
setIsLoading(false);
}}
onClearClick={() => {
setFile(undefined);
field.onChange(undefined);
setDataUri("");
}}
/>
</>
)}
/>
);
};

View File

@@ -0,0 +1,23 @@
import { createNamedContext } from "@keycloak/keycloak-ui-shared";
import { PropsWithChildren, useContext, useState } from "react";
type LogoContextProps = {
logo: string;
setLogo: (logo: string) => void;
};
export const LogoPreviewContext = createNamedContext<
LogoContextProps | undefined
>("LogoContext", undefined);
export const usePreviewLogo = () => useContext(LogoPreviewContext);
export const LogoContext = ({ children }: PropsWithChildren) => {
const [logo, setLogo] = useState("");
return (
<LogoPreviewContext.Provider value={{ logo, setLogo }}>
{children}
</LogoPreviewContext.Provider>
);
};

View File

@@ -0,0 +1,117 @@
// variable is the PF5 variable with the --pf-v5-global-- prefix removed
const variables = [
{
name: "font",
defaultValue: '"RedHatText", helvetica, arial, sans-serif',
variable: "FontFamily--text",
},
{
name: "errorColor",
defaultValue: { light: "#c9190b", dark: "#fe5142" },
variable: "danger-color--100",
},
{
name: "successColor",
defaultValue: { light: "#3e8635", dark: "#5ba352" },
variable: "success-color--100",
},
{
name: "activeColor",
defaultValue: { light: "#0066cc", dark: "#1fa7f8" },
variable: "active-color--100",
},
{
name: "primaryColor",
defaultValue: "#0066cc",
variable: { light: "primary-color--100", dark: "primary-color--300" },
},
{
name: "primaryColorHover",
defaultValue: "#004080",
variable: "primary-color--200",
},
{
name: "secondaryColor",
defaultValue: { light: "#0066cc", dark: "#1fa7f8" },
variable: "primary-color--100",
},
{
name: "linkColor",
defaultValue: { light: "#0066cc", dark: "#1fa7f8" },
variable: "link--Color",
},
{
name: "linkColorHover",
defaultValue: { light: "#004080", dark: "#73bcf7" },
variable: "link--Color--hover",
},
{
name: "backgroundColor",
defaultValue: { light: "#ffffff", dark: "#1b1d21" },
variable: "BackgroundColor--light-100",
},
{
name: "backgroundColorAccent",
defaultValue: "#26292d",
variable: { dark: "BackgroundColor--300" },
},
{
name: "backgroundColorNav",
defaultValue: { light: "#212427", dark: "#1b1d21" },
variable: {
light: "BackgroundColor--dark-300",
dark: "BackgroundColor--100",
},
},
{
name: "backgroundColorHeader",
defaultValue: { light: "#151515", dark: "#030303" },
variable: {
light: "BackgroundColor--dark-100",
dark: "palette--black-1000",
},
},
{ name: "iconColor", defaultValue: "#f0f0f0", variable: "Color--light-200" },
{
name: "textColor",
defaultValue: { light: "#151515", dark: "#e0e0e0" },
variable: "Color--100",
},
{
name: "lightTextColor",
defaultValue: { light: "#ffffff", dark: "#e0e0e0" },
variable: "Color--light-100",
},
{
name: "inputBackgroundColor",
defaultValue: "#36373a",
variable: { dark: "BackgroundColor--400" },
},
{
name: "inputTextColor",
defaultValue: { light: "#151515", dark: "#e0e0e0" },
variable: "Color--dark-100",
},
];
type Value = { light?: string; dark: string };
type ThemeType = keyof Value;
const convert = (v: string | Value, theme: ThemeType) =>
typeof v === "string" ? v : v[theme];
export const lightTheme = () =>
variables
.filter((v) => typeof v.defaultValue !== "string")
.map((v) => ({
name: v.name,
defaultValue: convert(v.defaultValue, "light"),
variable: convert(v.variable, "light"),
}));
export const darkTheme = () =>
variables.map((v) => ({
name: v.name,
defaultValue: convert(v.defaultValue, "dark"),
variable: convert(v.variable, "dark"),
}));

View File

@@ -0,0 +1,52 @@
import {
Alert,
Button,
Form,
Page,
PageSection,
Tab,
Tabs,
TabTitleText,
TextInput,
} from "@patternfly/react-core";
import { Header } from "../../PageHeader";
type PreviewWindowProps = {
cssVars: Record<string, string>;
};
export const PreviewWindow = ({ cssVars }: PreviewWindowProps) => (
<>
<style>{`
.preview {
${Object.entries(cssVars)
.map(([key, value]) => `--pf-v5-global--${key}: ${value};`)
.join("\n")}
}
`}</style>
<Page className="preview" header={<Header />}>
<PageSection
variant="light"
style={{
backgroundColor: cssVars["BackgroundColor--light-100"],
}}
>
<Tabs activeKey={1} className="pf-v5-u-p-lg">
<Tab eventKey={0} title={<TabTitleText>Tab One</TabTitleText>} />
<Tab eventKey={1} title={<TabTitleText>Tab Two</TabTitleText>} />
</Tabs>
<Alert title="Error" isInline variant="danger" />
<Alert title="Success" isInline variant="success" />
<p className="pf-v5-u-p-lg pf-v5-c-content">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
<Form>
<TextInput id="test" placeholder="Text input" />
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="link">Link button</Button>
</Form>
</PageSection>
</Page>
</>
);

View File

@@ -0,0 +1,184 @@
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { TextControl } from "@keycloak/keycloak-ui-shared";
import {
Alert,
Button,
Flex,
FlexItem,
FormGroup,
InputGroup,
InputGroupItem,
PageSection,
Text,
TextContent,
TextInputProps,
} from "@patternfly/react-core";
import { useEffect, useMemo } from "react";
import {
FormProvider,
useForm,
useFormContext,
useWatch,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FixedButtonsGroup } from "../../components/form/FixedButtonGroup";
import { FormAccess } from "../../components/form/FormAccess";
import { ImageUpload } from "./ImageUpload";
import { usePreviewLogo } from "./LogoContext";
import { darkTheme, lightTheme } from "./PatternflyVars";
import { PreviewWindow } from "./PreviewWindow";
import { ThemeRealmRepresentation } from "./ThemesTab";
type ThemeType = "light" | "dark";
type ColorControlProps = TextInputProps & {
name: string;
label: string;
color: string;
};
const ColorControl = ({ name, color, label, ...props }: ColorControlProps) => {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const currentValue = useWatch({
control,
name,
});
return (
<InputGroup>
<InputGroupItem isFill>
<TextControl {...props} name={name} label={t(label)} />
</InputGroupItem>
<input
type="color"
value={currentValue || color}
onChange={(e) => setValue(name, e.target.value)}
/>
</InputGroup>
);
};
const switchTheme = (theme: ThemeType) => {
if (theme === "light") {
document
.querySelector('meta[name="color-scheme"]')!
.setAttribute("content", "light");
document.documentElement.classList.remove("pf-v5-theme-dark");
} else {
document.documentElement.classList.add("pf-v5-theme-dark");
}
};
type ThemeColorsProps = {
realm: RealmRepresentation;
save: (realm: ThemeRealmRepresentation) => void;
theme: "light" | "dark";
};
export const ThemeColors = ({ realm, save, theme }: ThemeColorsProps) => {
const { t } = useTranslation();
const form = useForm();
const { handleSubmit, watch } = form;
const style = watch();
const contextLogo = usePreviewLogo();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const mapping = useMemo(
() => (theme === "light" ? lightTheme() : darkTheme()),
[],
);
const reset = () => {
form.reset({
[theme]: mapping.reduce(
(acc, m) => ({
...acc,
[m.variable!]: m.defaultValue,
}),
{},
),
});
};
const setupForm = () => {
const values = JSON.parse(realm.attributes?.style || "{}");
if (values[theme]) {
form.reset(values);
} else {
reset();
}
};
const convert = (values: Record<string, File | string>) => {
const styles = JSON.parse(realm.attributes?.style || "{}");
save({
...realm,
logo: values.logo as File,
bgimage: values.bgimage as File,
attributes: {
...realm.attributes,
style: JSON.stringify({
...styles,
...values,
}),
},
});
};
useEffect(() => {
setupForm();
switchTheme(theme);
return () => {
switchTheme(mediaQuery.matches ? "dark" : "light");
};
}, [realm]);
return (
<PageSection variant="light">
<TextContent className="pf-v5-u-mb-lg">
<Text>{t("themeColorInfo")}</Text>
</TextContent>
{mediaQuery.matches && theme === "light" && (
<Alert variant="info" isInline title={t("themePreviewInfo")} />
)}
<Flex className="pf-v5-u-pt-lg">
<FlexItem>
<FormAccess isHorizontal role="manage-realm">
<FormProvider {...form}>
<FormGroup label={t("logo")}>
<ImageUpload
name="logo"
onChange={(logo) => contextLogo?.setLogo(logo)}
/>
</FormGroup>
<FormGroup label={t("backgroundImage")}>
<ImageUpload name="bgimage" />
</FormGroup>
{mapping.map((m) => (
<ColorControl
key={m.name}
color={m.defaultValue!}
name={`${theme}.${m.variable!}`}
label={m.name}
/>
))}
</FormProvider>
</FormAccess>
</FlexItem>
<FlexItem grow={{ default: "grow" }} style={{ zIndex: 0 }}>
<PreviewWindow cssVars={style?.[theme] || {}} />
</FlexItem>
</Flex>
<FixedButtonsGroup
name="colors"
saveText={t("downloadThemeJar")}
save={handleSubmit(convert)}
reset={setupForm}
>
<Button type="button" variant="link" onClick={reset}>
{t("defaults")}
</Button>
</FixedButtonsGroup>
</PageSection>
);
};

View File

@@ -4,20 +4,17 @@ import { ActionGroup, Button, PageSection } from "@patternfly/react-core";
import { useEffect } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormAccess } from "../components/form/FormAccess";
import { DefaultSwitchControl } from "../components/SwitchControl";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { convertToFormValues } from "../util";
import { FormAccess } from "../../components/form/FormAccess";
import { DefaultSwitchControl } from "../../components/SwitchControl";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { convertToFormValues } from "../../util";
type RealmSettingsThemesTabProps = {
type ThemeSettingsTabProps = {
realm: RealmRepresentation;
save: (realm: RealmRepresentation) => void;
};
export const RealmSettingsThemesTab = ({
realm,
save,
}: RealmSettingsThemesTabProps) => {
export const ThemeSettingsTab = ({ realm, save }: ThemeSettingsTabProps) => {
const { t } = useTranslation();
const form = useForm<RealmRepresentation>();

View File

@@ -0,0 +1,192 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { useEnvironment } from "@keycloak/keycloak-ui-shared";
import { Tab, TabTitleText } from "@patternfly/react-core";
import JSZip from "jszip";
import { useTranslation } from "react-i18next";
import {
RoutableTabs,
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";
import { ThemeColors } from "./ThemeColors";
import { ThemeSettingsTab } from "./ThemeSettings";
type ThemesTabProps = {
realm: RealmRepresentation;
save: (realm: RealmRepresentation) => void;
};
export type ThemeRealmRepresentation = RealmRepresentation & {
logo?: File;
bgimage?: File;
};
export default function ThemesTab({ realm, save }: ThemesTabProps) {
const { t } = useTranslation();
const { realm: realmName } = useRealm();
const { environment } = useEnvironment();
const isFeatureEnabled = useIsFeatureEnabled();
const saveTheme = async (realm: ThemeRealmRepresentation) => {
const zip = new JSZip();
const styles = JSON.parse(realm.attributes?.style ?? "{}");
const { logo, bgimage, ...rest } = realm;
const logoName =
"img/logo" + logo?.name?.substring(logo?.name?.lastIndexOf("."));
const bgimageName =
"img/bgimage" + bgimage?.name?.substring(bgimage?.name?.lastIndexOf("."));
if (logo) {
zip.file(`theme/quick-theme/common/resources/${logoName}`, logo);
}
if (bgimage) {
zip.file(`theme/quick-theme/common/resources/${bgimageName}`, bgimage);
}
zip.file(
"theme/quick-theme/admin/theme.properties",
`
parent=keycloak.v2
import=common/quick-theme
logo=${logoName}
styles=css/theme-styles.css
`,
);
zip.file(
"theme/quick-theme/account/theme.properties",
`
parent=keycloak.v3
import=common/quick-theme
logo=${logoName}
styles=css/theme-styles.css
`,
);
zip.file(
"theme/quick-theme/login/theme.properties",
`
parent=keycloak.v2
import=common/quick-theme
styles=css/login.css css/theme-styles.css
`,
);
zip.file(
"META-INF/keycloak-themes.json",
`{
"themes": [{
"name" : "quick-theme",
"types": [ "login", "account", "admin", "common" ]
}]
}`,
);
const toCss = (obj?: object) =>
Object.entries(obj || {})
.map(([key, value]) => `--pf-v5-global--${key}: ${value};`)
.join("\n");
const logoCss = (
await fetch(joinPath(environment.resourceUrl, "/theme/login.css"))
).text();
zip.file("theme/quick-theme/common/resources/css/login.css", logoCss);
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;
${toCss(styles.light)}
}
.pf-v5-theme-dark {
${toCss(styles.dark)}
}
`,
);
save({
...rest,
attributes: {
...rest.attributes,
style: JSON.stringify({
...styles,
logo: logoName,
bgimage: bgimageName,
}),
},
});
zip.generateAsync({ type: "blob" }).then((content) => {
const url = URL.createObjectURL(content);
const a = document.createElement("a");
a.href = url;
a.download = "quick-theme.jar";
a.click();
URL.revokeObjectURL(url);
});
};
const param = (tab: ThemesTabType) => ({
realm: realmName,
tab,
});
const settingsTab = useRoutableTab(toThemesTab(param("settings")));
const lightColorsTab = useRoutableTab(toThemesTab(param("lightColors")));
const darkColorsTab = useRoutableTab(toThemesTab(param("darkColors")));
if (!isFeatureEnabled(Feature.QuickTheme)) {
return <ThemeSettingsTab realm={realm} save={save} />;
}
return (
<RoutableTabs
mountOnEnter
unmountOnExit
defaultLocation={toThemesTab({
realm: realmName,
tab: "settings",
})}
>
<Tab
id="themes-settings"
title={<TabTitleText>{t("themes")} </TabTitleText>}
data-testid="themes-settings-tab"
{...settingsTab}
>
<ThemeSettingsTab realm={realm} save={save} />
</Tab>
<Tab
id="lightColors"
title={<TabTitleText>{t("themeColorsLight")}</TabTitleText>}
data-testid="lightColors-tab"
{...lightColorsTab}
>
<LogoContext>
<ThemeColors realm={realm} save={saveTheme} theme="light" />
</LogoContext>
</Tab>
<Tab
id="darkColors"
title={<TabTitleText>{t("themeColorsDark")}</TabTitleText>}
data-testid="darkColors-tab"
{...darkColorsTab}
>
<LogoContext>
<ThemeColors realm={realm} save={saveTheme} theme="dark" />
</LogoContext>
</Tab>
</RoutableTabs>
);
}

View File

@@ -13,6 +13,7 @@ export enum Feature {
DeclarativeUI = "DECLARATIVE_UI",
Organizations = "ORGANIZATION",
OpenId4VCI = "OID4VC_VCI",
QuickTheme = "QUICK_THEME",
}
export default function useIsFeatureEnabled() {

36
js/pnpm-lock.yaml generated
View File

@@ -192,6 +192,9 @@ importers:
i18next-http-backend:
specifier: ^3.0.1
version: 3.0.1
jszip:
specifier: ^3.10.1
version: 3.10.1
keycloak-js:
specifier: workspace:*
version: link:../../libs/keycloak-js
@@ -3206,6 +3209,9 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@@ -3519,6 +3525,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3551,6 +3560,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-darwin-arm64@1.28.2:
resolution: {integrity: sha512-/8cPSqZiusHSS+WQz0W4NuaqFjquys1x+NsdN/XOHb+idGHJSoJ7SoQTVl3DZuAgtPZwFZgRfb/vd1oi8uX6+g==}
engines: {node: '>= 12.0.0'}
@@ -3912,6 +3924,9 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -4346,6 +4361,9 @@ packages:
resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -5684,6 +5702,7 @@ snapshots:
flat: 6.0.1
i18next: 24.0.2(typescript@5.7.2)
i18next-http-backend: 3.0.1
jszip: 3.10.1
keycloak-js: link:libs/keycloak-js
lodash-es: 4.17.21
monaco-editor: 0.52.0
@@ -8207,6 +8226,8 @@ snapshots:
ignore@5.3.2: {}
immediate@3.0.6: {}
import-fresh@3.3.0:
dependencies:
parent-module: 1.0.1
@@ -8490,6 +8511,13 @@ snapshots:
object.assign: 4.1.5
object.values: 1.2.0
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -8525,6 +8553,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-darwin-arm64@1.28.2:
optional: true
@@ -8898,6 +8930,8 @@ snapshots:
pako@0.2.9: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -9384,6 +9418,8 @@ snapshots:
functions-have-names: 1.2.3
has-property-descriptors: 1.0.2
setimmediate@1.0.5: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0

View File

@@ -1,3 +1,10 @@
:root {
--keycloak-logo-url: url('../img/keycloak-logo-text.png');
--keycloak-bg-logo-url: url("../img/keycloak-bg.png");
--keycloak-logo-height: 63px;
--keycloak-logo-width: 300px;
}
.pf-v5-c-login__container {
grid-template-columns: 34rem;
grid-template-areas: "header"
@@ -5,16 +12,17 @@
}
.login-pf body {
background: url("../img/keycloak-bg.png") no-repeat center center fixed;
background: var(--keycloak-bg-logo-url) no-repeat center center fixed;
background-size: cover;
height: 100%;
}
div.kc-logo-text {
background-image: url(../img/keycloak-logo-text.png);
background-image: var(--keycloak-logo-url);
height: var(--keycloak-logo-height);
width: var(--keycloak-logo-width);
background-repeat: no-repeat;
height: 63px;
width: 300px;
background-size: contain;
margin: 0 auto;
}