From 566e41cc72c133c9fa46c8e70edafe4210fa785c Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Wed, 4 Dec 2024 20:36:42 +0100 Subject: [PATCH] color theme tab (#35179) * added a way to customize theme colors fixes: #33233 Signed-off-by: Erik Jan de Wit * added preview and grouped vars Signed-off-by: Erik Jan de Wit * added dark mode Signed-off-by: Erik Jan de Wit * fixed label Signed-off-by: Erik Jan de Wit * added empty check Signed-off-by: Erik Jan de Wit * use json string in attributes Signed-off-by: Erik Jan de Wit * removed use of not exported type Signed-off-by: Erik Jan de Wit * output css based on JSON string Signed-off-by: Erik Jan de Wit * added feature flag Signed-off-by: Erik Jan de Wit * added a way to customize theme colors fixes: #33233 Signed-off-by: Erik Jan de Wit * renamed feature to quick theme Signed-off-by: Erik Jan de Wit * fixed merge error Signed-off-by: Erik Jan de Wit * Restore the Cache tab in Realm Settings (#34311) closes keycloak#17727 Signed-off-by: Christian Janker Signed-off-by: Erik Jan de Wit * added a way to customize theme colors fixes: #33233 Signed-off-by: Erik Jan de Wit * create a zip file instead Signed-off-by: Erik Jan de Wit * added themes.json to make jar usable Signed-off-by: Erik Jan de Wit * use property instead of attribute Signed-off-by: Erik Jan de Wit * fix the jar file Signed-off-by: Erik Jan de Wit * fixed header for preview and some text Signed-off-by: Erik Jan de Wit --------- Signed-off-by: Erik Jan de Wit Signed-off-by: Christian Janker Co-authored-by: Christian Ja --- .../java/org/keycloak/common/Profile.java | 2 + js/apps/account-ui/src/root/Header.tsx | 4 +- .../admin/messages/messages_en.properties | 28 ++- js/apps/admin-ui/package.json | 1 + js/apps/admin-ui/public/theme/login.css | 94 +++++++++ js/apps/admin-ui/src/PageHeader.tsx | 18 +- js/apps/admin-ui/src/index.ts | 4 +- .../src/realm-settings/RealmSettingsTabs.tsx | 4 +- js/apps/admin-ui/src/realm-settings/routes.ts | 30 +-- .../src/realm-settings/routes/ThemesTab.tsx | 26 +++ .../src/realm-settings/themes/ImageUpload.tsx | 69 +++++++ .../src/realm-settings/themes/LogoContext.tsx | 23 +++ .../realm-settings/themes/PatternflyVars.ts | 117 +++++++++++ .../realm-settings/themes/PreviewWindow.tsx | 52 +++++ .../src/realm-settings/themes/ThemeColors.tsx | 184 +++++++++++++++++ .../ThemeSettings.tsx} | 15 +- .../src/realm-settings/themes/ThemesTab.tsx | 192 ++++++++++++++++++ .../admin-ui/src/utils/useIsFeatureEnabled.ts | 1 + js/pnpm-lock.yaml | 36 ++++ .../login/resources/css/styles.css | 16 +- 20 files changed, 879 insertions(+), 37 deletions(-) create mode 100644 js/apps/admin-ui/public/theme/login.css create mode 100644 js/apps/admin-ui/src/realm-settings/routes/ThemesTab.tsx create mode 100644 js/apps/admin-ui/src/realm-settings/themes/ImageUpload.tsx create mode 100644 js/apps/admin-ui/src/realm-settings/themes/LogoContext.tsx create mode 100644 js/apps/admin-ui/src/realm-settings/themes/PatternflyVars.ts create mode 100644 js/apps/admin-ui/src/realm-settings/themes/PreviewWindow.tsx create mode 100644 js/apps/admin-ui/src/realm-settings/themes/ThemeColors.tsx rename js/apps/admin-ui/src/realm-settings/{ThemesTab.tsx => themes/ThemeSettings.tsx} (90%) create mode 100644 js/apps/admin-ui/src/realm-settings/themes/ThemesTab.tsx diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 577f8552236..123276126ea 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -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), diff --git a/js/apps/account-ui/src/root/Header.tsx b/js/apps/account-ui/src/root/Header.tsx index acc567cc1d1..5a704f5a875 100644 --- a/js/apps/account-ui/src/root/Header.tsx +++ b/js/apps/account-ui/src/root/Header.tsx @@ -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, }} 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 69ea0efa573..c56703517a8 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 @@ -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? \ No newline at end of file +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. \ No newline at end of file diff --git a/js/apps/admin-ui/package.json b/js/apps/admin-ui/package.json index 32c659fe7ca..cde589a780b 100644 --- a/js/apps/admin-ui/package.json +++ b/js/apps/admin-ui/package.json @@ -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", diff --git a/js/apps/admin-ui/public/theme/login.css b/js/apps/admin-ui/public/theme/login.css new file mode 100644 index 00000000000..ba03dbfea91 --- /dev/null +++ b/js/apps/admin-ui/public/theme/login.css @@ -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%; + } +} \ No newline at end of file diff --git a/js/apps/admin-ui/src/PageHeader.tsx b/js/apps/admin-ui/src/PageHeader.tsx index 02bea87cf14..b3ee4d3521c 100644 --- a/js/apps/admin-ui/src/PageHeader.tsx +++ b/js/apps/admin-ui/src/PageHeader.tsx @@ -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 = () => { { data-testid="rs-themes-tab" {...themesTab} > - + {t("keys")}} diff --git a/js/apps/admin-ui/src/realm-settings/routes.ts b/js/apps/admin-ui/src/realm-settings/routes.ts index 549271b72fd..160992e2d60 100644 --- a/js/apps/admin-ui/src/realm-settings/routes.ts +++ b/js/apps/admin-ui/src/realm-settings/routes.ts @@ -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; diff --git a/js/apps/admin-ui/src/realm-settings/routes/ThemesTab.tsx b/js/apps/admin-ui/src/realm-settings/routes/ThemesTab.tsx new file mode 100644 index 00000000000..57d109f7bb7 --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/routes/ThemesTab.tsx @@ -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: , + breadcrumb: (t) => t("themes"), + handle: { + access: "view-realm", + }, +}; + +export const toThemesTab = (params: ThemesParams): Partial => ({ + pathname: generateEncodedPath(ThemeTabRoute.path, params), +}); diff --git a/js/apps/admin-ui/src/realm-settings/themes/ImageUpload.tsx b/js/apps/admin-ui/src/realm-settings/themes/ImageUpload.tsx new file mode 100644 index 00000000000..8ae72908abf --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/ImageUpload.tsx @@ -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(); + const [isLoading, setIsLoading] = useState(false); + + const { control } = 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) { + fileToDataUri(file).then((dataUri) => { + setDataUri(dataUri); + onChange?.(dataUri); + }); + } + + return ( + ( + <> + {isLoading && } + {dataUri && } + setFile(file)} + onReadStarted={() => setIsLoading(true)} + onReadFinished={(_, file) => { + setFile(file); + field.onChange(file); + setIsLoading(false); + }} + onClearClick={() => { + setFile(undefined); + field.onChange(undefined); + setDataUri(""); + }} + /> + + )} + /> + ); +}; diff --git a/js/apps/admin-ui/src/realm-settings/themes/LogoContext.tsx b/js/apps/admin-ui/src/realm-settings/themes/LogoContext.tsx new file mode 100644 index 00000000000..93bc7946b3b --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/LogoContext.tsx @@ -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 ( + + {children} + + ); +}; diff --git a/js/apps/admin-ui/src/realm-settings/themes/PatternflyVars.ts b/js/apps/admin-ui/src/realm-settings/themes/PatternflyVars.ts new file mode 100644 index 00000000000..c514aafe14a --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/PatternflyVars.ts @@ -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"), + })); diff --git a/js/apps/admin-ui/src/realm-settings/themes/PreviewWindow.tsx b/js/apps/admin-ui/src/realm-settings/themes/PreviewWindow.tsx new file mode 100644 index 00000000000..2301919160d --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/PreviewWindow.tsx @@ -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; +}; + +export const PreviewWindow = ({ cssVars }: PreviewWindowProps) => ( + <> + + }> + + + Tab One} /> + Tab Two} /> + + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. +

+
+ + + + + +
+
+ +); diff --git a/js/apps/admin-ui/src/realm-settings/themes/ThemeColors.tsx b/js/apps/admin-ui/src/realm-settings/themes/ThemeColors.tsx new file mode 100644 index 00000000000..6cdec6ff1f1 --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/ThemeColors.tsx @@ -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 ( + + + + + setValue(name, e.target.value)} + /> + + ); +}; + +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) => { + 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 ( + + + {t("themeColorInfo")} + + {mediaQuery.matches && theme === "light" && ( + + )} + + + + + + contextLogo?.setLogo(logo)} + /> + + + + + {mapping.map((m) => ( + + ))} + + + + + + + + + + + + ); +}; diff --git a/js/apps/admin-ui/src/realm-settings/ThemesTab.tsx b/js/apps/admin-ui/src/realm-settings/themes/ThemeSettings.tsx similarity index 90% rename from js/apps/admin-ui/src/realm-settings/ThemesTab.tsx rename to js/apps/admin-ui/src/realm-settings/themes/ThemeSettings.tsx index 5e79c35de68..d1e82239d39 100644 --- a/js/apps/admin-ui/src/realm-settings/ThemesTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/themes/ThemeSettings.tsx @@ -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(); diff --git a/js/apps/admin-ui/src/realm-settings/themes/ThemesTab.tsx b/js/apps/admin-ui/src/realm-settings/themes/ThemesTab.tsx new file mode 100644 index 00000000000..5c56728539b --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/themes/ThemesTab.tsx @@ -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 ; + } + + return ( + + {t("themes")} } + data-testid="themes-settings-tab" + {...settingsTab} + > + + + {t("themeColorsLight")}} + data-testid="lightColors-tab" + {...lightColorsTab} + > + + + + + {t("themeColorsDark")}} + data-testid="darkColors-tab" + {...darkColorsTab} + > + + + + + + ); +} diff --git a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts index 2c233c92bd5..ac024e97675 100644 --- a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts +++ b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts @@ -13,6 +13,7 @@ export enum Feature { DeclarativeUI = "DECLARATIVE_UI", Organizations = "ORGANIZATION", OpenId4VCI = "OID4VC_VCI", + QuickTheme = "QUICK_THEME", } export default function useIsFeatureEnabled() { diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index fc7c16b7571..6c7ecfb9040 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -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 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 73edfb078ff..5fe14022230 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 @@ -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; }