From f27e2cebb4abb22ed7fe868bd2952aa642d27416 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:40:05 +0100
Subject: [PATCH] feat: lock or hide the settings the organisation pins or
hides
---
.../settings/AccessibilitySettings.tsx | 38 +++--
.../settings/AppearanceSettings.tsx | 39 +++--
src/components/settings/ComposingSettings.tsx | 13 +-
.../settings/NotificationSettings.tsx | 41 +++--
src/components/settings/ReadingSettings.tsx | 155 +++++++++++-------
.../__tests__/org-policy-settings.test.ts | 48 ++++++
6 files changed, 231 insertions(+), 103 deletions(-)
diff --git a/src/components/settings/AccessibilitySettings.tsx b/src/components/settings/AccessibilitySettings.tsx
index 1973bde8..65fc629c 100644
--- a/src/components/settings/AccessibilitySettings.tsx
+++ b/src/components/settings/AccessibilitySettings.tsx
@@ -4,6 +4,8 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s
import Button from '../Button';
import { spacing } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
+import { usePolicyStore, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
+import { policyAllowsValue } from '../../lib/org-policy';
import { APP_FONTS, FONT_SIZES, useSettingsStore, type AppFont, type FontSize } from '../../stores/settings-store';
import { uiFontsBundled } from '../../theme/reading-fonts';
import { toast } from '../../stores/toast-store';
@@ -81,6 +83,16 @@ export function AccessibilitySettings() {
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const toastDuration = useSettingsStore((s) => s.toastDuration);
const sendConfirmation = useSettingsStore((s) => s.sendConfirmation);
+ // What the organisation pins or hides (webmail accessibility-settings.tsx):
+ // a minimum notification duration leaves out the shorter choices.
+ const policyRules = usePolicyStore((s) => s.rules);
+ const toastDurationLocked = useSettingLocked('toastDuration');
+ const sendConfirmationLocked = useSettingLocked('sendConfirmation');
+ const sendConfirmationHidden = useSettingHidden('sendConfirmation');
+ const toastChoices = React.useMemo(
+ () => TOAST_DURATION_CHOICES.filter((value) => value === toastDuration || policyAllowsValue('toastDuration', value, policyRules)),
+ [toastDuration, policyRules],
+ );
const confirmDelete = useSettingsStore((s) => s.confirmDelete);
const confirmArchive = useSettingsStore((s) => s.confirmArchive);
@@ -214,23 +226,27 @@ export function AccessibilitySettings() {
-
- update('sendConfirmation', v)}
- accessibilityLabel={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
+ options={toastChoices.map((value) => ({ value: String(value), label: durationLabel(value) }))}
/>
+ {!sendConfirmationHidden && (
+
+ update('sendConfirmation', v)}
+ accessibilityLabel={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
+ />
+
+ )}
s.setTheme);
// The organisation's theme policy can pin the colour mode (#1280).
const themeLocked = useSettingLocked('theme');
+ const densityLocked = useSettingLocked('density');
+ const densityHidden = useSettingHidden('density');
const density = useSettingsStore((s) => s.density);
const setDensity = useSettingsStore((s) => s.setDensity);
const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels);
@@ -122,21 +124,25 @@ export function AppearanceSettings() {
link="page"
/>
-
-
- setDensity(v as Density)}
- options={[
- { value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") },
- { value: 'compact', label: t('settings.appearance.list_density.compact', "Compact") },
- { value: 'regular', label: t('settings.appearance.list_density.regular', "Regular") },
- { value: 'comfortable', label: t('settings.appearance.list_density.comfortable', "Comfortable") },
- ]}
- />
-
-
-
+ {!densityHidden && (
+
+
+
+ setDensity(v as Density)}
+ options={[
+ { value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") },
+ { value: 'compact', label: t('settings.appearance.list_density.compact', "Compact") },
+ { value: 'regular', label: t('settings.appearance.list_density.regular', "Regular") },
+ { value: 'comfortable', label: t('settings.appearance.list_density.comfortable', "Comfortable") },
+ ]}
+ />
+
+
+
+
+ )}
@@ -194,6 +200,7 @@ function makeStyles(c: ThemePalette) {
fontSize: 11,
color: c.textMuted,
},
+ lockedControl: { opacity: 0.6 },
divider: {
height: 1,
backgroundColor: c.border,
diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx
index 33471199..e2d5231c 100644
--- a/src/components/settings/ComposingSettings.tsx
+++ b/src/components/settings/ComposingSettings.tsx
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
+import { useSettingLocked } from '../../stores/policy-store';
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
import { RADIO_GROUP_A11Y, radioOptionA11y } from '../radio-a11y';
@@ -24,6 +25,10 @@ export function ComposingSettings() {
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
+ // Settings the organisation can pin (webmail lib/admin/restriction-keys.ts).
+ const autoSelectReplyIdentityLocked = useSettingLocked('autoSelectReplyIdentity');
+ const replyIdentityMatchLocked = useSettingLocked('replyIdentityMatch');
+ const plainTextModeLocked = useSettingLocked('plainTextMode');
const signaturePosition = useSettingsStore((s) => s.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
@@ -117,6 +122,7 @@ export function ComposingSettings() {
@@ -127,7 +133,10 @@ export function ComposingSettings() {
{t('settings.email_behavior.reply_identity_match.description', 'Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.')}
- {segmented(REPLY_IDENTITY_MATCHES, replyIdentityMatch, (v) => updateSetting('replyIdentityMatch', v))}
+ {replyIdentityMatchLocked && {t('settings.org_locked', 'Set by your organisation')}}
+
+ {segmented(REPLY_IDENTITY_MATCHES, replyIdentityMatch, (v) => updateSetting('replyIdentityMatch', v))}
+
)}
@@ -137,6 +146,7 @@ export function ComposingSettings() {
// the webmail's description (sending only) leaves out; same text as
// under Reading.
description={t('settings.email_behavior.plain_text_mode.description_mobile', 'Compose and read in plain text only.')}
+ locked={plainTextModeLocked}
>
updateSetting('plainTextMode', v)} />
@@ -271,6 +281,7 @@ function makeStyles(c: ThemePalette) {
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: c.text },
subDescription: { ...typography.caption, color: c.mutedForeground },
+ lockedControl: { opacity: 0.6 },
segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 },
segment: {
flex: 1,
diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx
index 95eb52a2..5e3f55be 100644
--- a/src/components/settings/NotificationSettings.tsx
+++ b/src/components/settings/NotificationSettings.tsx
@@ -19,6 +19,7 @@ import { useAuthStore } from '../../stores/auth-store';
import { useEmailStore } from '../../stores/email-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore } from '../../stores/settings-store';
+import { useSettingHidden, useSettingLocked } from '../../stores/policy-store';
import {
DEFAULT_RELAY_BASE_URL,
disablePushForAccount,
@@ -99,6 +100,13 @@ export function NotificationSettings() {
// The Inbox-only filter needs the server's emailPush filter too (webmail #983).
const inboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
+ // Settings the organisation pins or hides (webmail notification-settings.tsx).
+ const emailEnabledLocked = useSettingLocked('emailNotificationsEnabled');
+ const emailEnabledHidden = useSettingHidden('emailNotificationsEnabled');
+ const inboxOnlyLocked = useSettingLocked('pushNotifyInboxOnly');
+ const inboxOnlyHidden = useSettingHidden('pushNotifyInboxOnly');
+ const calEnabledLocked = useSettingLocked('calendarNotificationsEnabled');
+ const calEnabledHidden = useSettingHidden('calendarNotificationsEnabled');
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
@@ -570,16 +578,20 @@ export function NotificationSettings() {
title={t('settings.notifications.email.title', 'Email Notifications')}
description={t('settings.notifications.email.description', 'Configure notifications for incoming emails')}
>
-
- update('emailNotificationsEnabled', v)} />
-
- {inboxOnlyAvailable && (
+ {!emailEnabledHidden && (
+
+ update('emailNotificationsEnabled', v)} />
+
+ )}
+ {inboxOnlyAvailable && !inboxOnlyHidden && (
-
- update('calendarNotificationsEnabled', v)} />
-
+ {!calEnabledHidden && (
+
+ update('calendarNotificationsEnabled', v)} />
+
+ )}
s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const readReceiptResponse = useSettingsStore((s) => s.readReceiptResponse);
+ // Settings the organisation pins or hides (webmail reading-settings.tsx,
+ // message-list-settings.tsx, layout-settings.tsx).
+ const markAsReadLocked = useSettingLocked('markAsReadDelay');
+ const markAsReadHidden = useSettingHidden('markAsReadDelay');
+ const deleteActionLocked = useSettingLocked('deleteAction');
+ const deleteActionHidden = useSettingHidden('deleteAction');
+ const showPreviewLocked = useSettingLocked('showPreview');
+ const showPreviewHidden = useSettingHidden('showPreview');
+ const includeGroupLocked = useSettingLocked('includeGroupInUnified');
+ const includeGroupHidden = useSettingHidden('includeGroupInUnified');
+ const plainTextModeLocked = useSettingLocked('plainTextMode');
+ const autoSelectReplyIdentityLocked = useSettingLocked('autoSelectReplyIdentity');
+ const messageSpacingLocked = useSettingLocked('messageSpacing');
+ const messageSpacingHidden = useSettingHidden('messageSpacing');
+ const emailsPerPageLocked = useSettingLocked('emailsPerPage');
+ const emailsPerPageHidden = useSettingHidden('emailsPerPage');
const [newKeyword, setNewKeyword] = useState('');
const [reorganizing, setReorganizing] = useState(false);
@@ -118,38 +134,44 @@ export function ReadingSettings() {
return (
-
-
+ {!markAsReadHidden && (
+
+
+ )}
-
-
-
-
- update('showPreview', v)} />
-
+ {!showPreviewHidden && (
+
+ update('showPreview', v)} />
+
+ )}
update('showVerificationCodes', v)} />
@@ -208,15 +232,17 @@ export function ReadingSettings() {
update('disableThreading', v)} />
-
- update('includeGroupInUnified', v)} />
-
+ {!includeGroupHidden && (
+
+ update('includeGroupInUnified', v)} />
+
+ )}
-
+
update('plainTextMode', v)} />
-
+
update('autoSelectReplyIdentity', v)} />
@@ -320,17 +346,19 @@ export function ReadingSettings() {
/>
-
- update('messageSpacing', v as MessageSpacing)}
- options={[
- { value: 'auto', label: t('settings.email_behavior.message_spacing.auto', "Auto (recommended)") },
- { value: 'always', label: t('settings.email_behavior.message_spacing.always', "Always add spacing") },
- { value: 'edge', label: t('settings.email_behavior.message_spacing.edge', "Edge to edge") },
- ]}
- />
-
+ {!messageSpacingHidden && (
+
+ update('messageSpacing', v as MessageSpacing)}
+ options={[
+ { value: 'auto', label: t('settings.email_behavior.message_spacing.auto', "Auto (recommended)") },
+ { value: 'always', label: t('settings.email_behavior.message_spacing.always', "Always add spacing") },
+ { value: 'edge', label: t('settings.email_behavior.message_spacing.edge', "Edge to edge") },
+ ]}
+ />
+
+ )}
-
- update('emailsPerPage', Number(v))}
- options={[
- { value: '10', label: '10' },
- { value: '25', label: '25' },
- { value: '50', label: '50' },
- { value: '100', label: '100' },
- ]}
- />
-
+ {!emailsPerPageHidden && (
+
+ update('emailsPerPage', Number(v))}
+ options={[
+ { value: '10', label: '10' },
+ { value: '25', label: '25' },
+ { value: '50', label: '50' },
+ { value: '100', label: '100' },
+ ]}
+ />
+
+ )}
);
@@ -364,6 +394,7 @@ export function ReadingSettings() {
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
group: {},
+ lockedControl: { opacity: 0.6 },
warning: {
flexDirection: 'row',
gap: spacing.sm,
diff --git a/src/components/settings/__tests__/org-policy-settings.test.ts b/src/components/settings/__tests__/org-policy-settings.test.ts
index 97665eb9..a5bd4be8 100644
--- a/src/components/settings/__tests__/org-policy-settings.test.ts
+++ b/src/components/settings/__tests__/org-policy-settings.test.ts
@@ -63,4 +63,52 @@ describe('organisation policy on the settings pages', () => {
expect(reading).toContain("const archiveEnabled = usePolicyFeature('archiveActionEnabled');");
expect(reading).toMatch(/\{archiveEnabled && \(\s*\s* = [
+ ['AppearanceSettings', 'density', true],
+ ['AccessibilitySettings', 'toastDuration', false],
+ ['AccessibilitySettings', 'sendConfirmation', true],
+ ['ReadingSettings', 'markAsReadDelay', true],
+ ['ReadingSettings', 'deleteAction', true],
+ ['ReadingSettings', 'showPreview', true],
+ ['ReadingSettings', 'includeGroupInUnified', true],
+ ['ReadingSettings', 'plainTextMode', false],
+ ['ReadingSettings', 'autoSelectReplyIdentity', false],
+ ['ReadingSettings', 'messageSpacing', true],
+ ['ReadingSettings', 'emailsPerPage', true],
+ ['ComposingSettings', 'autoSelectReplyIdentity', false],
+ ['ComposingSettings', 'replyIdentityMatch', false],
+ ['ComposingSettings', 'plainTextMode', false],
+ ['NotificationSettings', 'emailNotificationsEnabled', true],
+ ['NotificationSettings', 'pushNotifyInboxOnly', true],
+ ['NotificationSettings', 'calendarNotificationsEnabled', true],
+ ];
+
+ it.each(LOCKABLE_ROWS)('%s marks %s as set by the organisation when locked', (page, key, hides) => {
+ const source = settings(page);
+ const locked = new RegExp(`const (\\w+) = useSettingLocked\\('${key}'\\);`).exec(source);
+ expect(locked, `useSettingLocked('${key}')`).not.toBeNull();
+ // On the row, or on the control of a row whose control sits outside it.
+ expect(source).toMatch(new RegExp(`locked=\\{${locked![1]}\\}|pointerEvents=\\{${locked![1]} \\? 'none' : 'auto'\\}`));
+ if (hides) {
+ const hidden = new RegExp(`const (\\w+) = useSettingHidden\\('${key}'\\);`).exec(source);
+ expect(hidden, `useSettingHidden('${key}')`).not.toBeNull();
+ expect(source).toMatch(new RegExp(`\\{!${hidden![1]} && \\(|&& !${hidden![1]} && \\(`));
+ }
+ });
+
+ it('keeps notification durations under the organisation minimum out of the choices', () => {
+ const page = settings('AccessibilitySettings');
+ expect(page).toContain("policyAllowsValue('toastDuration', value, policyRules)");
+ expect(page).toContain('options={toastChoices.map(');
+ });
+
+ it('keeps the controls of locked rows that sit outside their row still', () => {
+ expect(settings('AppearanceSettings')).toMatch(/]*>\s*]*>\s*]*>\s*\{segmented\(REPLY_IDENTITY_MATCHES/);
+ });
});