Add switch to toggle dark mode (#33822)

Closes #33821

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Jon Koops <jonkoops@gmail.com>
Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Erik Jan de Wit 2024-10-31 11:19:03 +01:00 committed by GitHub
parent 0d9d2908f1
commit 19ef0a608b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 93 additions and 193 deletions

View file

@ -9,6 +9,8 @@ If you are using a custom theme that extends any of the `keycloak` themes and ar
darkMode=false darkMode=false
---- ----
Alternatively, you can disable dark mode support for the built-in Keycloak themes on a per-realm basis by turning off the "Dark mode" setting under the "Theme" tab in the realm settings.
= LDAP users are created as enabled by default when using Microsoft Active Directory = LDAP users are created as enabled by default when using Microsoft Active Directory
If you are using Microsoft AD and creating users through the administrative interfaces, the user will created as enabled by default. If you are using Microsoft AD and creating users through the administrative interfaces, the user will created as enabled by default.

View file

@ -5,7 +5,7 @@
<base href="${resourceUrl}/"> <base href="${resourceUrl}/">
<link rel="icon" type="${properties.favIconType!'image/svg+xml'}" href="${resourceUrl}${properties.favIcon!'/favicon.svg'}"> <link rel="icon" type="${properties.favIconType!'image/svg+xml'}" href="${resourceUrl}${properties.favIcon!'/favicon.svg'}">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light${(properties.darkMode)?boolean?then(' dark', '')}"> <meta name="color-scheme" content="light${darkMode?then(' dark', '')}">
<meta name="description" content="${properties.description!'The Account Console is a web-based interface for managing your account.'}"> <meta name="description" content="${properties.description!'The Account Console is a web-based interface for managing your account.'}">
<title>${properties.title!'Account Management'}</title> <title>${properties.title!'Account Management'}</title>
<style> <style>
@ -58,7 +58,7 @@
} }
} }
</script> </script>
<#if properties.darkMode?boolean> <#if darkMode>
<script type="module" async blocking="render"> <script type="module" async blocking="render">
const DARK_MODE_CLASS = "${properties.kcDarkModeClass}"; const DARK_MODE_CLASS = "${properties.kcDarkModeClass}";
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

View file

@ -24,13 +24,13 @@ export default class RealmSettingsPage extends CommonPage {
userProfileTab = "rs-user-profile-tab"; userProfileTab = "rs-user-profile-tab";
tokensTab = "rs-tokens-tab"; tokensTab = "rs-tokens-tab";
selectLoginTheme = "#kc-login-theme"; selectLoginTheme = "#kc-login-theme";
loginThemeList = "[data-testid='select-login-theme']"; loginThemeList = "[data-testid='select-loginTheme']";
selectAccountTheme = "#kc-account-theme"; selectAccountTheme = "#kc-account-theme";
accountThemeList = "[data-testid='select-account-theme']"; accountThemeList = "[data-testid='select-accountTheme']";
selectAdminTheme = "#kc-admin-ui-theme"; selectAdminTheme = "#kc-admin-ui-theme";
adminThemeList = "[data-testid='select-admin-theme']"; adminThemeList = "[data-testid='select-adminTheme']";
selectEmailTheme = "#kc-email-theme"; selectEmailTheme = "#kc-email-theme";
emailThemeList = "[data-testid='select-email-theme']"; emailThemeList = "[data-testid='select-emailTheme']";
ssoSessionIdleSelectMenu = "#kc-sso-session-idle-select-menu"; ssoSessionIdleSelectMenu = "#kc-sso-session-idle-select-menu";
ssoSessionIdleSelectMenuList = "#kc-sso-session-idle-select-menu ul"; ssoSessionIdleSelectMenuList = "#kc-sso-session-idle-select-menu ul";
ssoSessionMaxSelectMenu = "#kc-sso-session-max-select-menu"; ssoSessionMaxSelectMenu = "#kc-sso-session-max-select-menu";

View file

@ -5,7 +5,7 @@
<base href="${resourceUrl}/"> <base href="${resourceUrl}/">
<link rel="icon" type="${properties.favIconType!'image/svg+xml'}" href="${resourceUrl}${properties.favIcon!'/favicon.svg'}"> <link rel="icon" type="${properties.favIconType!'image/svg+xml'}" href="${resourceUrl}${properties.favIcon!'/favicon.svg'}">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light${(properties.darkMode)?boolean?then(' dark', '')}"> <meta name="color-scheme" content="light${darkMode?then(' dark', '')}">
<meta name="description" content="${properties.description!'The Keycloak Administration Console is a web-based interface for managing Keycloak.'}"> <meta name="description" content="${properties.description!'The Keycloak Administration Console is a web-based interface for managing Keycloak.'}">
<title>${properties.title!'Keycloak Administration Console'}</title> <title>${properties.title!'Keycloak Administration Console'}</title>
<style> <style>
@ -60,7 +60,7 @@
} }
} }
</script> </script>
<#if properties.darkMode?boolean> <#if darkMode>
<script type="module" async blocking="render"> <script type="module" async blocking="render">
const DARK_MODE_CLASS = "${properties.kcDarkModeClass}"; const DARK_MODE_CLASS = "${properties.kcDarkModeClass}";
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

View file

@ -3273,6 +3273,8 @@ groupDuplicated=Group duplicated
duplicateAGroup=Duplicate group duplicateAGroup=Duplicate group
couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}} couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}}
duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups. duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups.
darkModeEnabled=Dark mode
darkModeEnabledHelp=If enabled the dark variant of the theme will be applied based on user preference through an operating system setting (e.g. light or dark mode) or a user agent setting, if disabled only the light variant will be used. This setting only applies to themes that support dark and light variants, on themes that do not support this feature it will have no effect.
showMemberships=Show memberships showMemberships=Show memberships
showMembershipsTitle={{username}} Group Memberships showMembershipsTitle={{username}} Group Memberships
noGroupMembershipsText=This user is not a member of any groups. noGroupMembershipsText=This user is not a member of any groups.

View file

@ -1,20 +1,11 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { import { SelectControl } from "@keycloak/keycloak-ui-shared";
HelpItem, import { ActionGroup, Button, PageSection } from "@patternfly/react-core";
KeycloakSelect, import { useEffect } from "react";
SelectVariant, import { FormProvider, useForm } from "react-hook-form";
} from "@keycloak/keycloak-ui-shared";
import {
ActionGroup,
Button,
FormGroup,
PageSection,
SelectOption,
} from "@patternfly/react-core";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormAccess } from "../components/form/FormAccess"; import { FormAccess } from "../components/form/FormAccess";
import { DefaultSwitchControl } from "../components/SwitchControl";
import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { convertToFormValues } from "../util"; import { convertToFormValues } from "../util";
@ -29,12 +20,8 @@ export const RealmSettingsThemesTab = ({
}: RealmSettingsThemesTabProps) => { }: RealmSettingsThemesTabProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [loginThemeOpen, setLoginThemeOpen] = useState(false); const form = useForm<RealmRepresentation>();
const [accountThemeOpen, setAccountThemeOpen] = useState(false); const { handleSubmit, setValue } = form;
const [adminUIThemeOpen, setAdminUIThemeOpen] = useState(false);
const [emailThemeOpen, setEmailThemeOpen] = useState(false);
const { control, handleSubmit, setValue } = useForm<RealmRepresentation>();
const themeTypes = useServerInfo().themes!; const themeTypes = useServerInfo().themes!;
const setupForm = () => { const setupForm = () => {
@ -42,6 +29,11 @@ export const RealmSettingsThemesTab = ({
}; };
useEffect(setupForm, []); useEffect(setupForm, []);
const appendEmptyChoice = (items: { key: string; value: string }[]) => [
{ key: "", value: t("choose") },
...items,
];
return ( return (
<PageSection variant="light"> <PageSection variant="light">
<FormAccess <FormAccess
@ -50,178 +42,70 @@ export const RealmSettingsThemesTab = ({
className="pf-v5-u-mt-lg" className="pf-v5-u-mt-lg"
onSubmit={handleSubmit(save)} onSubmit={handleSubmit(save)}
> >
<FormGroup <FormProvider {...form}>
label={t("loginTheme")} <DefaultSwitchControl
fieldId="kc-login-theme" name="attributes.darkMode"
labelIcon={ labelIcon={t("darkModeEnabledHelp")}
<HelpItem label={t("darkModeEnabled")}
helpText={t("loginThemeHelp")} defaultValue="true"
fieldLabelId="loginTheme" stringify
/> />
} <SelectControl
> id="kc-login-theme"
<Controller
name="loginTheme" name="loginTheme"
control={control} label={t("loginTheme")}
defaultValue="" labelIcon={t("loginThemeHelp")}
render={({ field }) => ( controller={{ defaultValue: "" }}
<KeycloakSelect options={appendEmptyChoice(
toggleId="kc-login-theme" themeTypes.login.map((theme) => ({
onToggle={() => setLoginThemeOpen(!loginThemeOpen)} key: theme.name,
onSelect={(value) => { value: theme.name,
field.onChange(value as string); })),
setLoginThemeOpen(false);
}}
selections={field.value}
variant={SelectVariant.single}
isOpen={loginThemeOpen}
placeholderText={t("selectATheme")}
data-testid="select-login-theme"
aria-label={t("selectLoginTheme")}
>
{themeTypes.login.map((theme, idx) => (
<SelectOption
selected={theme.name === field.value}
key={`login-theme-${idx}`}
value={theme.name}
>
{theme.name}
</SelectOption>
))}
</KeycloakSelect>
)} )}
/> />
</FormGroup> <SelectControl
<FormGroup id="kc-account-theme"
label={t("accountTheme")}
fieldId="kc-account-theme"
labelIcon={
<HelpItem
helpText={t("accountThemeHelp")}
fieldLabelId="accountTheme"
/>
}
>
<Controller
name="accountTheme" name="accountTheme"
control={control} label={t("accountTheme")}
defaultValue="" labelIcon={t("accountThemeHelp")}
render={({ field }) => (
<KeycloakSelect
toggleId="kc-account-theme"
onToggle={() => setAccountThemeOpen(!accountThemeOpen)}
onSelect={(value) => {
field.onChange(value as string);
setAccountThemeOpen(false);
}}
selections={field.value}
variant={SelectVariant.single}
aria-label={t("selectAccountTheme")}
isOpen={accountThemeOpen}
placeholderText={t("selectATheme")} placeholderText={t("selectATheme")}
data-testid="select-account-theme" controller={{ defaultValue: "" }}
> options={appendEmptyChoice(
{themeTypes.account themeTypes.account.map((theme) => ({
.filter((theme) => theme.name !== "base") key: theme.name,
.map((theme, idx) => ( value: theme.name,
<SelectOption })),
selected={theme.name === field.value}
key={`account-theme-${idx}`}
value={theme.name}
>
{t(theme.name)}
</SelectOption>
))}
</KeycloakSelect>
)} )}
/> />
</FormGroup> <SelectControl
<FormGroup id="kc-admin-theme"
label={t("adminTheme")}
fieldId="kc-admin-ui-theme"
labelIcon={
<HelpItem
helpText={t("adminThemeHelp")}
fieldLabelId="adminTheme"
/>
}
>
<Controller
name="adminTheme" name="adminTheme"
control={control} label={t("adminTheme")}
defaultValue="" labelIcon={t("adminThemeHelp")}
render={({ field }) => (
<KeycloakSelect
toggleId="kc-admin-ui-theme"
onToggle={() => setAdminUIThemeOpen(!adminUIThemeOpen)}
onSelect={(value) => {
field.onChange(value as string);
setAdminUIThemeOpen(false);
}}
selections={field.value}
variant={SelectVariant.single}
isOpen={adminUIThemeOpen}
placeholderText={t("selectATheme")} placeholderText={t("selectATheme")}
data-testid="select-admin-theme" controller={{ defaultValue: "" }}
aria-label={t("selectAdminTheme")} options={appendEmptyChoice(
> themeTypes.admin.map((theme) => ({
{themeTypes.admin key: theme.name,
.filter((theme) => theme.name !== "base") value: theme.name,
.map((theme, idx) => ( })),
<SelectOption
selected={theme.name === field.value}
key={`admin-theme-${idx}`}
value={theme.name}
>
{t(theme.name)}
</SelectOption>
))}
</KeycloakSelect>
)} )}
/> />
</FormGroup> <SelectControl
<FormGroup id="kc-email-theme"
label={t("emailTheme")}
fieldId="kc-email-theme"
labelIcon={
<HelpItem
helpText={t("emailThemeHelp")}
fieldLabelId="emailTheme"
/>
}
>
<Controller
name="emailTheme" name="emailTheme"
control={control} label={t("emailTheme")}
defaultValue="" labelIcon={t("emailThemeHelp")}
render={({ field }) => (
<KeycloakSelect
toggleId="kc-email-theme"
onToggle={() => setEmailThemeOpen(!emailThemeOpen)}
onSelect={(value) => {
field.onChange(value as string);
setEmailThemeOpen(false);
}}
selections={field.value}
variant={SelectVariant.single}
isOpen={emailThemeOpen}
placeholderText={t("selectATheme")} placeholderText={t("selectATheme")}
data-testid="select-email-theme" controller={{ defaultValue: "" }}
aria-label={t("selectEmailTheme")} options={appendEmptyChoice(
> themeTypes.email.map((theme) => ({
{themeTypes.email.map((theme, idx) => ( key: theme.name,
<SelectOption value: theme.name,
selected={theme.name === field.value} })),
key={`email-theme-${idx}`}
value={theme.name}
>
{t(theme.name)}
</SelectOption>
))}
</KeycloakSelect>
)} )}
/> />
</FormGroup> </FormProvider>
<ActionGroup> <ActionGroup>
<Button variant="primary" type="submit" data-testid="themes-tab-save"> <Button variant="primary" type="submit" data-testid="themes-tab-save">
{t("save")} {t("save")}

View file

@ -94,7 +94,7 @@ export const SingleSelectControl = <
}} }}
isOpen={open} isOpen={open}
> >
<SelectList> <SelectList data-testid={`select-${name}`}>
{options.map((option) => ( {options.map((option) => (
<SelectOption key={key(option)} value={key(option)}> <SelectOption key={key(option)} value={key(option)}>
{isString(option) ? option : option.value} {isString(option) ? option : option.value}

View file

@ -418,7 +418,10 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
} }
try { try {
attributes.put("properties", theme.getProperties()); Properties properties = theme.getProperties();
attributes.put("properties", properties);
attributes.put("darkMode", "true".equals(properties.getProperty("darkMode"))
&& realm.getAttribute("darkMode", true));
} catch (IOException e) { } catch (IOException e) {
logger.warn("Failed to load properties", e); logger.warn("Failed to load properties", e);
} }

View file

@ -194,6 +194,8 @@ public class KeycloakErrorHandler implements ExceptionMapper<Throwable> {
String errorMessage = messagesBundle.getProperty(errorKey); String errorMessage = messagesBundle.getProperty(errorKey);
attributes.put("message", new MessageBean(errorMessage, MessageType.ERROR)); attributes.put("message", new MessageBean(errorMessage, MessageType.ERROR));
// Default fallback in case an error occurs determining the dark mode later on.
attributes.put("darkMode", true);
try { try {
attributes.put("msg", new MessageFormatterMethod(locale, theme.getMessages(locale))); attributes.put("msg", new MessageFormatterMethod(locale, theme.getMessages(locale)));
@ -202,7 +204,10 @@ public class KeycloakErrorHandler implements ExceptionMapper<Throwable> {
} }
try { try {
attributes.put("properties", theme.getProperties()); Properties properties = theme.getProperties();
attributes.put("properties", properties);
attributes.put("darkMode", "true".equals(properties.getProperty("darkMode"))
&& realm.getAttribute("darkMode", true));
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); e.printStackTrace();
} }

View file

@ -159,6 +159,8 @@ public class AccountConsole implements AccountResourceProvider {
map.put("msgJSON", messagesToJsonString(messages)); map.put("msgJSON", messagesToJsonString(messages));
map.put("supportedLocales", supportedLocales(messages)); map.put("supportedLocales", supportedLocales(messages));
map.put("properties", theme.getProperties()); map.put("properties", theme.getProperties());
map.put("darkMode", "true".equals(theme.getProperties().getProperty("darkMode"))
&& realm.getAttribute("darkMode", true));
map.put("theme", (Function<String, String>) file -> { map.put("theme", (Function<String, String>) file -> {
try { try {
final InputStream resource = theme.getResourceAsStream(file); final InputStream resource = theme.getResourceAsStream(file);

View file

@ -367,6 +367,8 @@ public class AdminConsole {
map.put("loginRealm", realm.getName()); map.put("loginRealm", realm.getName());
map.put("clientId", Constants.ADMIN_CONSOLE_CLIENT_ID); map.put("clientId", Constants.ADMIN_CONSOLE_CLIENT_ID);
map.put("properties", theme.getProperties()); map.put("properties", theme.getProperties());
map.put("darkMode", "true".equals(theme.getProperties().getProperty("darkMode"))
&& realm.getAttribute("darkMode", true));
final var devServerUrl = Environment.isDevMode() ? System.getenv(ViteManifest.ADMIN_VITE_URL) : null; final var devServerUrl = Environment.isDevMode() ? System.getenv(ViteManifest.ADMIN_VITE_URL) : null;

View file

@ -29,7 +29,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light${(properties.darkMode)?boolean?then(' dark', '')}"> <meta name="color-scheme" content="light${darkMode?then(' dark', '')}">
<#if properties.meta?has_content> <#if properties.meta?has_content>
<#list properties.meta?split(' ') as meta> <#list properties.meta?split(' ') as meta>
@ -55,7 +55,7 @@
} }
} }
</script> </script>
<#if properties.darkMode?boolean> <#if darkMode>
<script type="module" async blocking="render"> <script type="module" async blocking="render">
const DARK_MODE_CLASS = "${properties.kcDarkModeClass}"; const DARK_MODE_CLASS = "${properties.kcDarkModeClass}";
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");