From a69bc071d16f44d06389819168a9d31e4c84e495 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:24:16 +0100 Subject: [PATCH] feat: hide the themes the organisation turns off and keep a locked theme in place --- src/components/settings/ThemesSettings.tsx | 31 ++++++++++++++++--- .../__tests__/org-policy-settings.test.ts | 10 ++++++ .../settings/__tests__/theme-cards.test.ts | 25 +++++++++++++++ src/components/settings/theme-cards.ts | 16 ++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 src/components/settings/__tests__/theme-cards.test.ts create mode 100644 src/components/settings/theme-cards.ts diff --git a/src/components/settings/ThemesSettings.tsx b/src/components/settings/ThemesSettings.tsx index 37383f01..957fe01f 100644 --- a/src/components/settings/ThemesSettings.tsx +++ b/src/components/settings/ThemesSettings.tsx @@ -7,6 +7,8 @@ import { resolvePalette, useColors, useResolvedTheme } from '../../theme/colors' import { BUILTIN_THEMES } from '../../theme/builtin-themes'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; +import { usePolicyStore, useSettingLocked } from '../../stores/policy-store'; +import { visibleThemeCards } from './theme-cards'; interface ThemeCard { id: string | null; @@ -28,19 +30,23 @@ export function ThemesSettings() { const hydrate = useSettingsStore((s) => s.hydrate); const active = useSettingsStore((s) => s.activeThemeId); const update = useSettingsStore((s) => s.updateSetting); + // The organisation can turn built-in themes off, and pin one (or the + // default, with themes turned off) for everyone (webmail themes-settings.tsx). + const policy = usePolicyStore((s) => s.effective); + const themeLocked = useSettingLocked('activeThemeId'); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); - const cards: ThemeCard[] = [ + const cards = React.useMemo(() => visibleThemeCards([ { id: null, name: t('settings.themes.default_name', 'Bulwark'), description: t('settings.themes.default_description', 'The default light and dark palettes.'), }, ...BUILTIN_THEMES.map((theme) => ({ id: theme.id, name: theme.name, description: theme.description })), - ]; + ], policy), [t, policy]); return ( + {themeLocked && ( + + {t('settings.themes.locked', 'Theme selection is locked by an administrator.')} + + )} {cards.map((card) => { const isActive = (active ?? null) === card.id; @@ -58,10 +69,11 @@ export function ThemesSettings() { update('activeThemeId', card.id)} + disabled={themeLocked} accessibilityRole="radio" - accessibilityState={{ selected: isActive, checked: isActive }} + accessibilityState={{ selected: isActive, checked: isActive, disabled: themeLocked }} accessibilityLabel={card.name} - style={[styles.card, isActive && styles.cardActive]} + style={[styles.card, isActive && styles.cardActive, themeLocked && !isActive && styles.cardDisabled]} > @@ -112,6 +124,17 @@ function makeStyles(c: ThemePalette) { borderColor: c.primary, backgroundColor: c.primaryBg, }, + cardDisabled: { opacity: 0.6 }, + lockedNote: { + marginBottom: spacing.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.warning, + backgroundColor: c.warningBg, + }, + lockedNoteText: { ...typography.caption, color: c.text }, preview: { width: '100%', aspectRatio: 16 / 10, diff --git a/src/components/settings/__tests__/org-policy-settings.test.ts b/src/components/settings/__tests__/org-policy-settings.test.ts index 353c9e79..9f5eb132 100644 --- a/src/components/settings/__tests__/org-policy-settings.test.ts +++ b/src/components/settings/__tests__/org-policy-settings.test.ts @@ -16,4 +16,14 @@ describe('organisation policy on the settings pages', () => { expect(page).toContain("const themeLocked = useSettingLocked('theme');"); expect(page).toMatch(/label=\{t\('settings\.appearance\.theme\.label', "Theme"\)\}[^>]*locked=\{themeLocked\}>/); }); + + it('hides the themes the policy turns off and keeps the grid still while the theme is locked', () => { + const page = settings('ThemesSettings'); + expect(page).toContain('const policy = usePolicyStore((s) => s.effective);'); + expect(page).toMatch(/visibleThemeCards\(\[[\s\S]*\], policy\), \[t, policy\]\)/); + expect(page).toContain('{cards.map((card) => {'); + expect(page).toContain("const themeLocked = useSettingLocked('activeThemeId');"); + expect(page).toContain('disabled={themeLocked}'); + expect(page).toContain("{themeLocked && ("); + }); }); diff --git a/src/components/settings/__tests__/theme-cards.test.ts b/src/components/settings/__tests__/theme-cards.test.ts new file mode 100644 index 00000000..fe5070a1 --- /dev/null +++ b/src/components/settings/__tests__/theme-cards.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { visibleThemeCards } from '../theme-cards'; +import { sanitizePolicy } from '../../../lib/org-policy'; + +const CARDS = [ + { id: null, name: 'Bulwark' }, + { id: 'nord', name: 'Nord' }, + { id: 'solarized', name: 'Solarized' }, +]; + +describe('visibleThemeCards', () => { + it('offers every card without a policy', () => { + expect(visibleThemeCards(CARDS, null).map((c) => c.id)).toEqual([null, 'nord', 'solarized']); + }); + + it('leaves out the built-in themes the policy turns off', () => { + const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord'] } }); + expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null, 'solarized']); + }); + + it('keeps the default card whatever the policy lists', () => { + const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord', 'solarized', 'null', ''] } }); + expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null]); + }); +}); diff --git a/src/components/settings/theme-cards.ts b/src/components/settings/theme-cards.ts new file mode 100644 index 00000000..0fed0eac --- /dev/null +++ b/src/components/settings/theme-cards.ts @@ -0,0 +1,16 @@ +import type { OrgPolicy } from '../../lib/org-policy'; + +/** + * The theme cards the Themes page offers: every card but the built-in themes + * the organisation's policy turns off (webmail themes-settings.tsx filters + * them with isThemeDisabled). The default card (id null) always stays, so + * there is something to pick. + */ +export function visibleThemeCards( + cards: readonly T[], + policy: OrgPolicy | null | undefined, +): T[] { + const disabled = policy?.themePolicy.disabledBuiltinThemes ?? []; + if (disabled.length === 0) return [...cards]; + return cards.filter((card) => card.id === null || !disabled.includes(card.id)); +}