From 1d654bbeaf1d000eeee1b70a8ad5414915daa764 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:30:15 +0100 Subject: [PATCH] feat: leave out the settings pages the organisation turns off --- .../settings/__tests__/policy-pages.test.ts | 56 +++++++++++++++++++ src/components/settings/policy-pages.ts | 42 ++++++++++++++ src/screens/SettingsScreen.tsx | 7 ++- 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/__tests__/policy-pages.test.ts create mode 100644 src/components/settings/policy-pages.ts diff --git a/src/components/settings/__tests__/policy-pages.test.ts b/src/components/settings/__tests__/policy-pages.test.ts new file mode 100644 index 00000000..2eb9d0b1 --- /dev/null +++ b/src/components/settings/__tests__/policy-pages.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { policyHiddenPages } from '../policy-pages'; +import { sanitizePolicy } from '../../../lib/org-policy'; + +const screen = fs.readFileSync(path.resolve(__dirname, '..', '..', '..', 'screens', 'SettingsScreen.tsx'), 'utf8'); + +describe('policyHiddenPages', () => { + it('leaves every page without a policy or with the default gates', () => { + expect(policyHiddenPages(null)).toEqual([]); + expect(policyHiddenPages(sanitizePolicy({}))).toEqual([]); + }); + + it('takes away the pages of the gates the policy turns off', () => { + const policy = sanitizePolicy({ + features: { + templatesEnabled: false, + customKeywordsEnabled: false, + sidebarAppsEnabled: false, + themesEnabled: false, + calendarEnabled: false, + contactsEnabled: false, + filesEnabled: false, + }, + }); + expect(policyHiddenPages(policy).sort()).toEqual( + ['calendar', 'contacts', 'files', 'keywords', 'sidebar_apps', 'templates', 'themes'], + ); + }); + + it('takes away Delegates and Recover deleted items when the policy hides them', () => { + const policy = sanitizePolicy({ + restrictions: { delegation: { hidden: true }, deletedItemsRecovery: { hidden: true } }, + }); + expect(policyHiddenPages(policy).sort()).toEqual(['recovery', 'sharing']); + const locked = sanitizePolicy({ restrictions: { delegation: { locked: true } } }); + expect(policyHiddenPages(locked)).toEqual([]); + }); + + it('names pages the Settings list has', () => { + const policy = sanitizePolicy({ + features: Object.fromEntries(['templatesEnabled', 'customKeywordsEnabled', 'sidebarAppsEnabled', 'themesEnabled', 'calendarEnabled', 'contactsEnabled', 'filesEnabled'].map((g) => [g, false])), + restrictions: { delegation: { hidden: true }, deletedItemsRecovery: { hidden: true } }, + }); + for (const page of policyHiddenPages(policy)) expect(screen).toContain(`{ id: '${page}',`); + }); +}); + +describe('Settings list', () => { + it('leaves out the pages the active policy takes away', () => { + expect(screen).toContain('const policy = usePolicyStore((s) => s.effective);'); + expect(screen).toContain('for (const page of policyHiddenPages(policy)) set.add(page as Tab);'); + expect(screen).toContain('}, [recoveryAvailable, delegationAvailable, policy]);'); + }); +}); diff --git a/src/components/settings/policy-pages.ts b/src/components/settings/policy-pages.ts new file mode 100644 index 00000000..29bc9997 --- /dev/null +++ b/src/components/settings/policy-pages.ts @@ -0,0 +1,42 @@ +import { isFeatureEnabled, type FeatureGate, type OrgPolicy } from '../../lib/org-policy'; + +/** + * Settings pages a feature gate takes away, as the webmail's settings-app.tsx + * leaves their tabs out: Templates (templatesEnabled, in Composing there), + * Keywords (customKeywordsEnabled: Organize turns into Folders), Sidebar apps + * (sidebarAppsEnabled: the app has no plugins, which keep Add-ons there), + * Themes (themesEnabled, appearance-settings.tsx), and Calendar, Contacts and + * Files with their apps. + */ +const GATED_PAGES: ReadonlyArray<[FeatureGate, string]> = [ + ['templatesEnabled', 'templates'], + ['customKeywordsEnabled', 'keywords'], + ['sidebarAppsEnabled', 'sidebar_apps'], + ['themesEnabled', 'themes'], + ['calendarEnabled', 'calendar'], + ['contactsEnabled', 'contacts'], + ['filesEnabled', 'files'], +]; + +/** + * Pages a hidden restriction takes away: Delegates (`delegation`, webmail + * settings-app supportsSharing) and Recover deleted items + * (`deletedItemsRecovery`, webmail hooks/use-deleted-items-recovery.ts). + */ +const HIDDEN_PAGES: ReadonlyArray<[string, string]> = [ + ['delegation', 'sharing'], + ['deletedItemsRecovery', 'recovery'], +]; + +/** The settings pages the active account's policy leaves out, by page id. */ +export function policyHiddenPages(policy: OrgPolicy | null | undefined): string[] { + if (!policy) return []; + const out: string[] = []; + for (const [gate, page] of GATED_PAGES) { + if (!isFeatureEnabled(policy, gate)) out.push(page); + } + for (const [key, page] of HIDDEN_PAGES) { + if (policy.restrictions[key]?.hidden === true) out.push(page); + } + return out; +} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index a1463bcd..6a5a5fd9 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -54,6 +54,8 @@ import { useAuthStore } from '../stores/auth-store'; import { useManagedAccountStore } from '../stores/managed-account-store'; import { useAccountStore } from '../stores/account-store'; import { useSettingsStore } from '../stores/settings-store'; +import { usePolicyStore } from '../stores/policy-store'; +import { policyHiddenPages } from '../components/settings/policy-pages'; import { useUpdatesStore } from '../stores/updates-store'; import { SUPPORTED_LOCALES } from '../i18n'; import { settingsSummary, type SummaryState } from '../lib/settings-summary'; @@ -231,12 +233,15 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin // list, not shown as "Not on this server", where the server lacks them. const recoveryAvailable = useDeletedItemsRecovery().available; const delegationAvailable = useDelegationAvailable(); + // Pages the organisation's policy takes away (feature gates, hidden pages). + const policy = usePolicyStore((s) => s.effective); const hiddenTabs = React.useMemo(() => { const set = new Set(); if (!recoveryAvailable) set.add('recovery'); if (!delegationAvailable) set.add('sharing'); + for (const page of policyHiddenPages(policy)) set.add(page as Tab); return set; - }, [recoveryAvailable, delegationAvailable]); + }, [recoveryAvailable, delegationAvailable, policy]); // A shared/group account picked under "Shared with me" scopes Settings to // the panes that account can be managed in (webmail: scoped settings).