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
----
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
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}/">
<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="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.'}">
<title>${properties.title!'Account Management'}</title>
<style>
@ -58,7 +58,7 @@
}
}
</script>
<#if properties.darkMode?boolean>
<#if darkMode>
<script type="module" async blocking="render">
const DARK_MODE_CLASS = "${properties.kcDarkModeClass}";
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";
tokensTab = "rs-tokens-tab";
selectLoginTheme = "#kc-login-theme";
loginThemeList = "[data-testid='select-login-theme']";
loginThemeList = "[data-testid='select-loginTheme']";
selectAccountTheme = "#kc-account-theme";
accountThemeList = "[data-testid='select-account-theme']";
accountThemeList = "[data-testid='select-accountTheme']";
selectAdminTheme = "#kc-admin-ui-theme";
adminThemeList = "[data-testid='select-admin-theme']";
adminThemeList = "[data-testid='select-adminTheme']";
selectEmailTheme = "#kc-email-theme";
emailThemeList = "[data-testid='select-email-theme']";
emailThemeList = "[data-testid='select-emailTheme']";
ssoSessionIdleSelectMenu = "#kc-sso-session-idle-select-menu";
ssoSessionIdleSelectMenuList = "#kc-sso-session-idle-select-menu ul";
ssoSessionMaxSelectMenu = "#kc-sso-session-max-select-menu";

View file

@ -5,7 +5,7 @@
<base href="${resourceUrl}/">
<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="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.'}">
<title>${properties.title!'Keycloak Administration Console'}</title>
<style>
@ -60,7 +60,7 @@
}
}
</script>
<#if properties.darkMode?boolean>
<#if darkMode>
<script type="module" async blocking="render">
const DARK_MODE_CLASS = "${properties.kcDarkModeClass}";
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

View file

@ -3273,6 +3273,8 @@ groupDuplicated=Group duplicated
duplicateAGroup=Duplicate group
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.
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
showMembershipsTitle={{username}} Group Memberships
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 {
HelpItem,
KeycloakSelect,
SelectVariant,
} 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 { SelectControl } from "@keycloak/keycloak-ui-shared";
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";
@ -29,12 +20,8 @@ export const RealmSettingsThemesTab = ({
}: RealmSettingsThemesTabProps) => {
const { t } = useTranslation();
const [loginThemeOpen, setLoginThemeOpen] = useState(false);
const [accountThemeOpen, setAccountThemeOpen] = useState(false);
const [adminUIThemeOpen, setAdminUIThemeOpen] = useState(false);
const [emailThemeOpen, setEmailThemeOpen] = useState(false);
const { control, handleSubmit, setValue } = useForm<RealmRepresentation>();
const form = useForm<RealmRepresentation>();
const { handleSubmit, setValue } = form;
const themeTypes = useServerInfo().themes!;
const setupForm = () => {
@ -42,6 +29,11 @@ export const RealmSettingsThemesTab = ({
};
useEffect(setupForm, []);
const appendEmptyChoice = (items: { key: string; value: string }[]) => [
{ key: "", value: t("choose") },
...items,
];
return (
<PageSection variant="light">
<FormAccess
@ -50,178 +42,70 @@ export const RealmSettingsThemesTab = ({
className="pf-v5-u-mt-lg"
onSubmit={handleSubmit(save)}
>
<FormGroup
label={t("loginTheme")}
fieldId="kc-login-theme"
labelIcon={
<HelpItem
helpText={t("loginThemeHelp")}
fieldLabelId="loginTheme"
/>
}
>
<Controller
<FormProvider {...form}>
<DefaultSwitchControl
name="attributes.darkMode"
labelIcon={t("darkModeEnabledHelp")}
label={t("darkModeEnabled")}
defaultValue="true"
stringify
/>
<SelectControl
id="kc-login-theme"
name="loginTheme"
control={control}
defaultValue=""
render={({ field }) => (
<KeycloakSelect
toggleId="kc-login-theme"
onToggle={() => setLoginThemeOpen(!loginThemeOpen)}
onSelect={(value) => {
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>
label={t("loginTheme")}
labelIcon={t("loginThemeHelp")}
controller={{ defaultValue: "" }}
options={appendEmptyChoice(
themeTypes.login.map((theme) => ({
key: theme.name,
value: theme.name,
})),
)}
/>
</FormGroup>
<FormGroup
label={t("accountTheme")}
fieldId="kc-account-theme"
labelIcon={
<HelpItem
helpText={t("accountThemeHelp")}
fieldLabelId="accountTheme"
/>
}
>
<Controller
<SelectControl
id="kc-account-theme"
name="accountTheme"
control={control}
defaultValue=""
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")}
data-testid="select-account-theme"
>
{themeTypes.account
.filter((theme) => theme.name !== "base")
.map((theme, idx) => (
<SelectOption
selected={theme.name === field.value}
key={`account-theme-${idx}`}
value={theme.name}
>
{t(theme.name)}
</SelectOption>
))}
</KeycloakSelect>
label={t("accountTheme")}
labelIcon={t("accountThemeHelp")}
placeholderText={t("selectATheme")}
controller={{ defaultValue: "" }}
options={appendEmptyChoice(
themeTypes.account.map((theme) => ({
key: theme.name,
value: theme.name,
})),
)}
/>
</FormGroup>
<FormGroup
label={t("adminTheme")}
fieldId="kc-admin-ui-theme"
labelIcon={
<HelpItem
helpText={t("adminThemeHelp")}
fieldLabelId="adminTheme"
/>
}
>
<Controller
<SelectControl
id="kc-admin-theme"
name="adminTheme"
control={control}
defaultValue=""
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")}
data-testid="select-admin-theme"
aria-label={t("selectAdminTheme")}
>
{themeTypes.admin
.filter((theme) => theme.name !== "base")
.map((theme, idx) => (
<SelectOption
selected={theme.name === field.value}
key={`admin-theme-${idx}`}
value={theme.name}
>
{t(theme.name)}
</SelectOption>
))}
</KeycloakSelect>
label={t("adminTheme")}
labelIcon={t("adminThemeHelp")}
placeholderText={t("selectATheme")}
controller={{ defaultValue: "" }}
options={appendEmptyChoice(
themeTypes.admin.map((theme) => ({
key: theme.name,
value: theme.name,
})),
)}
/>
</FormGroup>
<FormGroup
label={t("emailTheme")}
fieldId="kc-email-theme"
labelIcon={
<HelpItem
helpText={t("emailThemeHelp")}
fieldLabelId="emailTheme"
/>
}
>
<Controller
<SelectControl
id="kc-email-theme"
name="emailTheme"
control={control}
defaultValue=""
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")}
data-testid="select-email-theme"
aria-label={t("selectEmailTheme")}
>
{themeTypes.email.map((theme, idx) => (
<SelectOption
selected={theme.name === field.value}
key={`email-theme-${idx}`}
value={theme.name}
>
{t(theme.name)}
</SelectOption>
))}
</KeycloakSelect>
label={t("emailTheme")}
labelIcon={t("emailThemeHelp")}
placeholderText={t("selectATheme")}
controller={{ defaultValue: "" }}
options={appendEmptyChoice(
themeTypes.email.map((theme) => ({
key: theme.name,
value: theme.name,
})),
)}
/>
</FormGroup>
</FormProvider>
<ActionGroup>
<Button variant="primary" type="submit" data-testid="themes-tab-save">
{t("save")}

View file

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

View file

@ -418,7 +418,10 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
}
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) {
logger.warn("Failed to load properties", e);
}

View file

@ -194,6 +194,8 @@ public class KeycloakErrorHandler implements ExceptionMapper<Throwable> {
String errorMessage = messagesBundle.getProperty(errorKey);
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 {
attributes.put("msg", new MessageFormatterMethod(locale, theme.getMessages(locale)));
@ -202,7 +204,10 @@ public class KeycloakErrorHandler implements ExceptionMapper<Throwable> {
}
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) {
e.printStackTrace();
}

View file

@ -159,6 +159,8 @@ public class AccountConsole implements AccountResourceProvider {
map.put("msgJSON", messagesToJsonString(messages));
map.put("supportedLocales", supportedLocales(messages));
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 -> {
try {
final InputStream resource = theme.getResourceAsStream(file);

View file

@ -367,6 +367,8 @@ public class AdminConsole {
map.put("loginRealm", realm.getName());
map.put("clientId", Constants.ADMIN_CONSOLE_CLIENT_ID);
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;

View file

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