feat: hide the themes the organisation turns off and keep a locked theme in place

This commit is contained in:
Linus Rath
2026-10-31 15:24:16 +01:00
parent b7b3dd4c4d
commit a69bc071d1
4 changed files with 78 additions and 4 deletions
+27 -4
View File
@@ -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<ThemeCard[]>(() => 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 (
<SettingsSection
@@ -50,6 +56,11 @@ export function ThemesSettings() {
'Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.',
)}
>
{themeLocked && (
<View style={styles.lockedNote}>
<Text style={styles.lockedNoteText}>{t('settings.themes.locked', 'Theme selection is locked by an administrator.')}</Text>
</View>
)}
<View style={styles.grid}>
{cards.map((card) => {
const isActive = (active ?? null) === card.id;
@@ -58,10 +69,11 @@ export function ThemesSettings() {
<Pressable
key={card.id ?? 'default'}
onPress={() => 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]}
>
<View style={[styles.preview, { backgroundColor: preview.background, borderColor: preview.border }]}>
<View style={[styles.previewRail, { backgroundColor: preview.surface, borderRightColor: preview.border }]} />
@@ -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,
@@ -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 && (");
});
});
@@ -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]);
});
});
+16
View File
@@ -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<T extends { id: string | null }>(
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));
}