feat: apply the organisation policy of the webmail an account signed in through

This commit is contained in:
Linus Rath
2026-10-31 15:13:02 +01:00
parent ab2e5a60f8
commit 9d9777738e
16 changed files with 1787 additions and 37 deletions
+21 -4
View File
@@ -61,6 +61,7 @@ import { useEmailStore } from './src/stores/email-store';
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache'; import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store'; import { useSettingsStore } from './src/stores/settings-store';
import { startPolicySync, usePolicyFeature } from './src/stores/policy-store';
import { useLocaleStore } from './src/stores/locale-store'; import { useLocaleStore } from './src/stores/locale-store';
import { useLayout } from './src/lib/use-layout'; import { useLayout } from './src/lib/use-layout';
import { toast } from './src/stores/toast-store'; import { toast } from './src/stores/toast-store';
@@ -231,6 +232,12 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
const hasContacts = useHasContacts(); const hasContacts = useHasContacts();
const hasFiles = useHasFiles(); const hasFiles = useHasFiles();
const disabledTabStyle = { opacity: 0.4 } as const; const disabledTabStyle = { opacity: 0.4 } as const;
// The organisation policy can turn a whole area off (webmail feature
// gates); its tab goes away rather than showing as unavailable.
const calendarAllowed = usePolicyFeature('calendarEnabled');
const contactsAllowed = usePolicyFeature('contactsEnabled');
const filesAllowed = usePolicyFeature('filesEnabled');
const hiddenTab = { tabBarItemStyle: { display: 'none' as const }, tabBarButton: () => null };
const t = useLocaleStore((state) => state.t); const t = useLocaleStore((state) => state.t);
// The webmail's navigation names; a tab the server lacks says so. // The webmail's navigation names; a tab the server lacks says so.
const tabLabels = { const tabLabels = {
@@ -323,10 +330,11 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />, tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle, tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar), tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
...(calendarAllowed ? null : hiddenTab),
}} }}
listeners={{ listeners={{
tabPress: (e) => { tabPress: (e) => {
if (!hasCalendar) e.preventDefault(); if (!hasCalendar || !calendarAllowed) e.preventDefault();
}, },
}} }}
/> />
@@ -339,10 +347,11 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />, tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle, tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts), tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
...(contactsAllowed ? null : hiddenTab),
}} }}
listeners={{ listeners={{
tabPress: (e) => { tabPress: (e) => {
if (!hasContacts) e.preventDefault(); if (!hasContacts || !contactsAllowed) e.preventDefault();
}, },
}} }}
/> />
@@ -355,10 +364,11 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />, tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle, tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files), tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
...(filesAllowed ? null : hiddenTab),
}} }}
listeners={{ listeners={{
tabPress: (e) => { tabPress: (e) => {
if (!hasFiles) e.preventDefault(); if (!hasFiles || !filesAllowed) e.preventDefault();
}, },
}} }}
/> />
@@ -421,7 +431,14 @@ export default function App() {
// Device sync (Android, #34): live changes, the app's own edits and the // Device sync (Android, #34): live changes, the app's own edits and the
// foreground ask Android to sync; accounts are checked against Android's. // foreground ask Android to sync; accounts are checked against Android's.
startDeviceSyncTriggers(); startDeviceSyncTriggers();
return useNetworkStore.getState().init(); // The organisation policy of the active account's webmail: the cached
// one at once, a fresh one on start and on every account switch.
const stopPolicySync = startPolicySync();
const stopNetwork = useNetworkStore.getState().init();
return () => {
stopPolicySync();
stopNetwork?.();
};
}, []); }, []);
// Home-screen widgets follow the signed-in data; once the last account is // Home-screen widgets follow the signed-in data; once the last account is
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Where the organisation policy reaches the UI. No React Native renderer in
// the test env, so this checks the wiring; what the policy does to the
// settings is tested in org-policy.test.ts, policy-store.test.ts and
// settings-store-policy.test.ts.
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', '..', rel), 'utf8');
describe('organisation policy in the UI', () => {
it('starts following the policy at launch', () => {
expect(read('App.tsx')).toMatch(/const stopPolicySync = startPolicySync\(\);/);
});
it('hides the Calendar, Contacts and Files tabs a policy turns off', () => {
const app = read('App.tsx');
for (const [gate, name] of [['calendarEnabled', 'calendar'], ['contactsEnabled', 'contacts'], ['filesEnabled', 'files']]) {
expect(app).toContain(`const ${name}Allowed = usePolicyFeature('${gate}');`);
expect(app).toContain(`...(${name}Allowed ? null : hiddenTab),`);
}
});
it('shows a locked setting as set by the organisation and keeps its control still', () => {
const item = read('src/components/settings/settings-section.tsx');
expect(item).toContain("{locked && <Text style={styles.settingDescription}>{t('settings.org_locked', 'Set by your organisation')}</Text>}");
expect(item).toContain("pointerEvents={locked ? 'none' : 'auto'}");
});
it('marks the settings of gated features as locked', () => {
const layout = read('src/components/settings/LayoutSettings.tsx');
for (const key of ['unifiedCrossAccount', 'enableCrossUnreadView', 'enableCrossStarredView', 'enableCrossAllView']) {
expect(layout).toContain(`useSettingLocked('${key}')`);
}
expect(read('src/components/settings/TroubleshootingSettings.tsx')).toMatch(/locked=\{debugModeLocked\}/);
});
it("lists the operator's help links on About", () => {
const about = read('src/components/settings/AboutSettings.tsx');
expect(about).toContain('const operatorLinks = usePolicyStore((s) => s.links);');
expect(about).toContain('{helpLinks(operatorLinks).map((link) => (');
});
});
+21
View File
@@ -12,6 +12,8 @@ import { useAccountStore } from '../../stores/account-store';
import { useUpdatesStore } from '../../stores/updates-store'; import { useUpdatesStore } from '../../stores/updates-store';
import { supportsSideloadUpdates } from '../../lib/platform-capabilities'; import { supportsSideloadUpdates } from '../../lib/platform-capabilities';
import { buildIssueUrl, sourceCodeUrl, updateSourceUrl } from '../../lib/about-links'; import { buildIssueUrl, sourceCodeUrl, updateSourceUrl } from '../../lib/about-links';
import { helpLinks } from '../../lib/org-policy';
import { usePolicyStore } from '../../stores/policy-store';
const LOGO_WHITE = require('../../../assets/logos/Bulwark Logo White.png'); const LOGO_WHITE = require('../../../assets/logos/Bulwark Logo White.png');
const LOGO_COLOR = require('../../../assets/logos/Bulwark Logo Color.png'); const LOGO_COLOR = require('../../../assets/logos/Bulwark Logo Color.png');
@@ -35,11 +37,21 @@ interface AboutSettingsProps {
onOpen?: (page: 'updates' | 'licences' | 'privacy') => void; onOpen?: (page: 'updates' | 'licences' | 'privacy') => void;
} }
// English labels of the webmail's help_links keys.
const HELP_LINK_LABELS = {
statement: 'Accessibility statement',
feedback: 'Report a barrier',
help: 'Help',
easy_read: 'Easy Read',
sign_language: 'Sign language',
} as const;
export function AboutSettings({ onOpen }: AboutSettingsProps) { export function AboutSettings({ onOpen }: AboutSettingsProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const scheme = useResolvedTheme(); const scheme = useResolvedTheme();
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const operatorLinks = usePolicyStore((s) => s.links);
const locale = useLocaleStore((s) => s.locale); const locale = useLocaleStore((s) => s.locale);
const authUsername = useAuthStore((s) => s.username); const authUsername = useAuthStore((s) => s.username);
@@ -157,6 +169,15 @@ export function AboutSettings({ onOpen }: AboutSettingsProps) {
description={t('settings.about.privacy_desc', 'What stays on this phone and what goes to your server')} description={t('settings.about.privacy_desc', 'What stays on this phone and what goes to your server')}
onPress={() => onOpen?.('privacy')} onPress={() => onOpen?.('privacy')}
/> />
{/* The operator's accessibility and help links, from the account's webmail. */}
{helpLinks(operatorLinks).map((link) => (
<SettingItem
key={link.id}
label={t(`help_links.${link.id}`, HELP_LINK_LABELS[link.id])}
onPress={() => void Linking.openURL(link.href)}
link="external"
/>
))}
<SettingItem <SettingItem
label={t('settings.about.report_problem', 'Report a problem')} label={t('settings.about.report_problem', 'Report a problem')}
description={t('settings.about.report_problem_desc', 'Opens an issue on GitHub with the version filled in')} description={t('settings.about.report_problem_desc', 'Opens an issue on GitHub with the version filled in')}
@@ -16,6 +16,7 @@ import {
} from '../../stores/settings-store'; } from '../../stores/settings-store';
import { useEmailStore } from '../../stores/email-store'; import { useEmailStore } from '../../stores/email-store';
import { useKeywordsStore } from '../../stores/keywords-store'; import { useKeywordsStore } from '../../stores/keywords-store';
import { useSettingLocked } from '../../stores/policy-store';
import { import {
ORDER_PRESETS, detectPreset, presetLevels, sanitizeSortLevels, ORDER_PRESETS, detectPreset, presetLevels, sanitizeSortLevels,
type OrderPreset, type MessageListOrderScope, type OrderPreset, type MessageListOrderScope,
@@ -93,6 +94,10 @@ export function LayoutSettings() {
const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView); const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView);
const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView); const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView);
const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView); const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView);
const unifiedCrossAccountLocked = useSettingLocked('unifiedCrossAccount');
const crossUnreadLocked = useSettingLocked('enableCrossUnreadView');
const crossStarredLocked = useSettingLocked('enableCrossStarredView');
const crossAllLocked = useSettingLocked('enableCrossAllView');
const keywordDefs = useKeywordsStore((s) => s.keywords); const keywordDefs = useKeywordsStore((s) => s.keywords);
const invalidateListOrder = useEmailStore((s) => s.invalidateListOrder); const invalidateListOrder = useEmailStore((s) => s.invalidateListOrder);
const order = React.useMemo(() => sanitizeSortLevels(messageListOrderRaw), [messageListOrderRaw]); const order = React.useMemo(() => sanitizeSortLevels(messageListOrderRaw), [messageListOrderRaw]);
@@ -185,18 +190,21 @@ export function LayoutSettings() {
<SettingItem <SettingItem
label={t('settings.layout.unified_cross_account', 'Unified views across accounts')} label={t('settings.layout.unified_cross_account', 'Unified views across accounts')}
description={t('settings.layout.unified_cross_account_description', 'Combine every signed-in account in "All inboxes". Off keeps the unified views inside the active account and its shared folders.')} description={t('settings.layout.unified_cross_account_description', 'Combine every signed-in account in "All inboxes". Off keeps the unified views inside the active account and its shared folders.')}
locked={unifiedCrossAccountLocked}
> >
<ToggleSwitch checked={unifiedCrossAccount} onChange={(v) => update('unifiedCrossAccount', v)} /> <ToggleSwitch checked={unifiedCrossAccount} onChange={(v) => update('unifiedCrossAccount', v)} />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
label={t('settings.appearance.cross_unread.label_mobile', 'All unread')} label={t('settings.appearance.cross_unread.label_mobile', 'All unread')}
description={t('settings.appearance.cross_unread.description', 'Show an Unread entry in the Unified Mailbox listing unread mail across the selected folders.')} description={t('settings.appearance.cross_unread.description', 'Show an Unread entry in the Unified Mailbox listing unread mail across the selected folders.')}
locked={crossUnreadLocked}
> >
<ToggleSwitch checked={enableCrossUnreadView} onChange={(v) => update('enableCrossUnreadView', v)} /> <ToggleSwitch checked={enableCrossUnreadView} onChange={(v) => update('enableCrossUnreadView', v)} />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
label={t('settings.appearance.cross_starred.label_mobile', 'All starred')} label={t('settings.appearance.cross_starred.label_mobile', 'All starred')}
description={t('settings.appearance.cross_starred.description', 'Show a Starred entry in the Unified Mailbox listing flagged/starred mail across the selected folders.')} description={t('settings.appearance.cross_starred.description', 'Show a Starred entry in the Unified Mailbox listing flagged/starred mail across the selected folders.')}
locked={crossStarredLocked}
> >
<ToggleSwitch checked={enableCrossStarredView} onChange={(v) => update('enableCrossStarredView', v)} /> <ToggleSwitch checked={enableCrossStarredView} onChange={(v) => update('enableCrossStarredView', v)} />
</SettingItem> </SettingItem>
@@ -204,6 +212,7 @@ export function LayoutSettings() {
label={t('settings.appearance.cross_all.label_mobile', 'All mail')} label={t('settings.appearance.cross_all.label_mobile', 'All mail')}
description={t('settings.appearance.cross_all.description', 'Show an All mail entry in the Unified Mailbox listing all mail across the selected folders.')} description={t('settings.appearance.cross_all.description', 'Show an All mail entry in the Unified Mailbox listing all mail across the selected folders.')}
noBorder noBorder
locked={crossAllLocked}
> >
<ToggleSwitch checked={enableCrossAllView} onChange={(v) => update('enableCrossAllView', v)} /> <ToggleSwitch checked={enableCrossAllView} onChange={(v) => update('enableCrossAllView', v)} />
</SettingItem> </SettingItem>
@@ -6,6 +6,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { ALL_DEBUG_CATEGORIES, useSettingsStore, type DebugCategory } from '../../stores/settings-store'; import { ALL_DEBUG_CATEGORIES, useSettingsStore, type DebugCategory } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { useSettingLocked } from '../../stores/policy-store';
import { clearCachedData } from '../../lib/clear-cached-data'; import { clearCachedData } from '../../lib/clear-cached-data';
/** Settings → Storage & backup → Troubleshooting (webmail: the Debug page). */ /** Settings → Storage & backup → Troubleshooting (webmail: the Debug page). */
@@ -14,6 +15,7 @@ export function TroubleshootingSettings() {
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const debugMode = useSettingsStore((s) => s.debugMode); const debugMode = useSettingsStore((s) => s.debugMode);
const debugModeLocked = useSettingLocked('debugMode');
const debugCategories = useSettingsStore((s) => s.debugCategories); const debugCategories = useSettingsStore((s) => s.debugCategories);
const updateSetting = useSettingsStore((s) => s.updateSetting); const updateSetting = useSettingsStore((s) => s.updateSetting);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@@ -56,7 +58,7 @@ export function TroubleshootingSettings() {
title={t('settings.advanced.debug_mode.label', 'Debug Mode')} title={t('settings.advanced.debug_mode.label', 'Debug Mode')}
description={t('settings.advanced.debug_mode.description', 'Enable detailed logging for troubleshooting')} description={t('settings.advanced.debug_mode.description', 'Enable detailed logging for troubleshooting')}
> >
<SettingItem label={t('settings.advanced.debug_mode.label', 'Debug Mode')} noBorder={!debugMode}> <SettingItem label={t('settings.advanced.debug_mode.label', 'Debug Mode')} noBorder={!debugMode} locked={debugModeLocked}>
<ToggleSwitch checked={debugMode} onChange={(v) => updateSetting('debugMode', v)} /> <ToggleSwitch checked={debugMode} onChange={(v) => updateSetting('debugMode', v)} />
</SettingItem> </SettingItem>
{debugMode && ( {debugMode && (
+4 -1
View File
@@ -98,6 +98,7 @@ export function SettingItem({
}: SettingItemProps) { }: SettingItemProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const highlight = useSearchHighlight(label); const highlight = useSearchHighlight(label);
// The control sits on the right. When that leaves the label column narrower // The control sits on the right. When that leaves the label column narrower
// than STACK_BELOW (a long translation, two buttons), the control moves // than STACK_BELOW (a long translation, two buttons), the control moves
@@ -125,10 +126,12 @@ export function SettingItem({
{locked && <Lock size={12} color={c.mutedForeground} />} {locked && <Lock size={12} color={c.mutedForeground} />}
</View> </View>
{description && <Text style={styles.settingDescription}>{description}</Text>} {description && <Text style={styles.settingDescription}>{description}</Text>}
{locked && <Text style={styles.settingDescription}>{t('settings.org_locked', 'Set by your organisation')}</Text>}
</View> </View>
{children && ( {children && (
<SettingLabelContext.Provider value={label}> <SettingLabelContext.Provider value={label}>
<View style={styles.settingRight}>{children}</View> {/* A locked setting shows its value but can't be changed. */}
<View style={styles.settingRight} pointerEvents={locked ? 'none' : 'auto'}>{children}</View>
</SettingLabelContext.Provider> </SettingLabelContext.Provider>
)} )}
{onPress && (link === 'external' {onPress && (link === 'external'
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, it } from 'vitest';
import {
isFeatureEnabled,
isLockedSetting,
policyReleasePatch,
policySettingsPatch,
resolvePolicy,
sanitizePolicy,
settingsRulesFromPolicy,
webmailLinksFromConfig,
helpLinks,
type PolicyPatchContext,
} from '../org-policy';
// Policy → effective settings, ported from the webmail's policy-scopes.ts
// (resolvePolicy) and settings-store.ts (adminPolicyPatch,
// adminPolicyReleasePatch).
const keyFor = (key: string) => (key === 'firstDayOfWeek' ? 'calendarFirstDayOfWeek' : ['density', 'theme', 'markAsReadDelay', 'fontSize', 'toastDuration', 'activeThemeId', 'enableCalendarTasks', 'debugMode', 'unifiedCrossAccount', 'enableCrossUnreadView', 'enableCrossStarredView', 'enableCrossAllView'].includes(key) ? key : null);
const opts = { keyFor, themeIds: ['builtin-nord', 'builtin-qui'] };
const builtIn = {
density: 'regular',
theme: 'system',
markAsReadDelay: 0,
calendarFirstDayOfWeek: 1,
fontSize: 'medium',
toastDuration: -1,
activeThemeId: null,
enableCalendarTasks: false,
debugMode: false,
unifiedCrossAccount: false,
enableCrossUnreadView: false,
enableCrossStarredView: false,
enableCrossAllView: false,
};
const ctx = (explicit: string[] = []): PolicyPatchContext => ({
builtIn,
explicit: new Set(explicit),
isValid: (key, value) => key === 'activeThemeId'
? value === null || typeof value === 'string'
: typeof value === typeof (builtIn as Record<string, unknown>)[key],
});
const policy = (raw: Record<string, unknown>) => sanitizePolicy({ restrictions: {}, features: {}, defaults: {}, themePolicy: {}, ...raw })!;
describe('sanitizePolicy', () => {
it('keeps known parts with the right types and drops the rest', () => {
const p = sanitizePolicy({
restrictions: { density: { locked: true, value: 'compact', allowedValues: ['compact'] }, __proto__: { locked: true }, bad: 5 },
features: { calendarEnabled: false, filesEnabled: 'no' },
defaults: { density: 'compact', list: [1], n: Infinity },
themePolicy: { defaultColorMode: 'purple', lockColorMode: true, disabledBuiltinThemes: ['builtin-nord', 3] },
forceEnabledThemes: ['x', 1],
})!;
expect(p.restrictions).toEqual({ density: { locked: true, value: 'compact', allowedValues: ['compact'] } });
expect(p.features).toEqual({ calendarEnabled: false });
expect(p.defaults).toEqual({ density: 'compact' });
expect(p.themePolicy).toMatchObject({ defaultColorMode: null, lockColorMode: true, disabledBuiltinThemes: ['builtin-nord'] });
expect(p.forceEnabledThemes).toEqual(['x']);
expect(sanitizePolicy('nope')).toBeNull();
expect(sanitizePolicy([])).toBeNull();
});
});
describe('resolvePolicy', () => {
const raw = policy({
defaults: { density: 'regular' },
features: { calendarEnabled: true },
scopes: [
{ id: 'sales', match: { groups: ['Sales@example.com'] }, policy: { defaults: { density: 'compact' } } },
{ id: 'eu', match: { domains: ['*.example.com'] }, policy: { features: { calendarEnabled: false }, defaults: { density: 'comfortable' } } },
{ id: 'other', match: { domains: ['other.org'] }, policy: { features: { filesEnabled: false } } },
],
});
it('applies domain scopes, then group scopes, later winning', () => {
const effective = resolvePolicy(raw, { email: 'ada@eu.example.com', groups: ['sales@example.com'] });
expect(effective.defaults.density).toBe('compact');
expect(effective.features.calendarEnabled).toBe(false);
expect(effective.features.filesEnabled).toBeUndefined();
expect(effective.scopes).toEqual([]);
});
it('applies only the global policy without a matching scope or subject', () => {
expect(resolvePolicy(raw, { email: 'bob@example.net', groups: [] }).defaults.density).toBe('regular');
expect(resolvePolicy(raw, null).features.calendarEnabled).toBe(true);
// *.example.com covers subdomains, not the domain itself.
expect(resolvePolicy(raw, { email: 'ada@example.com', groups: [] }).features.calendarEnabled).toBe(true);
});
});
describe('isFeatureEnabled', () => {
it('reads the gate, falls back to the webmail default, and allows everything without a policy', () => {
expect(isFeatureEnabled(null, 'calendarEnabled')).toBe(true);
expect(isFeatureEnabled(null, 'unifiedCrossAccountEnabled')).toBe(true);
expect(isFeatureEnabled(policy({ features: { calendarEnabled: false } }), 'calendarEnabled')).toBe(false);
expect(isFeatureEnabled(policy({}), 'contactsEnabled')).toBe(true);
expect(isFeatureEnabled(policy({}), 'crossUnreadViewEnabled')).toBe(false);
expect(isFeatureEnabled(policy({ features: { allMailViewEnabled: true } }), 'crossAllViewEnabled')).toBe(true);
});
});
describe('settingsRulesFromPolicy', () => {
it('maps webmail keys to the app, locks to their value or default, and skips keys the app lacks', () => {
const rules = settingsRulesFromPolicy(policy({
defaults: { firstDayOfWeek: 0, density: 'compact', webmailOnly: true },
restrictions: { density: { locked: true }, markAsReadDelay: { locked: true, value: 3000 }, webmailOnly: { locked: true, value: 1 }, fontSize: { hidden: true } },
}), opts);
expect(rules.defaults).toEqual({ calendarFirstDayOfWeek: 0, density: 'compact' });
expect(rules.locks).toMatchObject({ density: 'compact', markAsReadDelay: 3000 });
expect(rules.locks).not.toHaveProperty('webmailOnly');
expect(rules.hidden).toEqual(['fontSize']);
});
it('pins the settings of gates that are off', () => {
const rules = settingsRulesFromPolicy(policy({ features: { calendarTasksEnabled: false, debugModeEnabled: false, crossUnreadViewEnabled: true } }), opts);
expect(rules.locks).toMatchObject({ enableCalendarTasks: false, debugMode: false, unifiedCrossAccount: false, enableCrossStarredView: false, enableCrossAllView: false });
expect(rules.locks).not.toHaveProperty('enableCrossUnreadView');
});
it('turns the theme policy into a color-mode default or lock and theme rules', () => {
expect(settingsRulesFromPolicy(policy({ themePolicy: { defaultColorMode: 'dark' } }), opts).defaults.theme).toBe('dark');
expect(settingsRulesFromPolicy(policy({ themePolicy: { lockColorMode: true } }), opts).locks.theme).toBe('system');
const themed = settingsRulesFromPolicy(policy({ themePolicy: { defaultThemeId: 'builtin-qui', disabledBuiltinThemes: ['builtin-nord', 'builtin-unknown'] } }), opts);
expect(themed.defaults.activeThemeId).toBe('builtin-qui');
expect(themed.blocked.activeThemeId).toEqual(['builtin-nord']);
expect(settingsRulesFromPolicy(policy({ forceEnabledThemes: ['webmail-zip', 'builtin-nord'] }), opts).locks.activeThemeId).toBe('builtin-nord');
expect(settingsRulesFromPolicy(policy({ features: { themesEnabled: false } }), opts).locks.activeThemeId).toBeNull();
});
it('is empty without a policy', () => {
expect(settingsRulesFromPolicy(null, opts)).toEqual({ defaults: {}, locks: {}, restrictions: {}, blocked: {}, hidden: [] });
});
});
describe('policySettingsPatch', () => {
const state = { ...builtIn };
it('fills defaults only into settings the user has not chosen', () => {
const rules = settingsRulesFromPolicy(policy({ defaults: { density: 'compact', markAsReadDelay: 2000 } }), opts);
expect(policySettingsPatch(state, rules, ctx())).toEqual({ density: 'compact', markAsReadDelay: 2000 });
expect(policySettingsPatch(state, rules, ctx(['density']))).toEqual({ markAsReadDelay: 2000 });
});
it('pins a locked value whatever the user chose', () => {
const rules = settingsRulesFromPolicy(policy({ restrictions: { density: { locked: true, value: 'compact' } } }), opts);
expect(policySettingsPatch({ ...state, density: 'comfortable' }, rules, ctx(['density']))).toEqual({ density: 'compact' });
expect(isLockedSetting(rules, 'density')).toBe(true);
});
it('treats a lock on a reading aid as a default the user may change', () => {
const rules = settingsRulesFromPolicy(policy({ restrictions: { fontSize: { locked: true, value: 'large' } } }), opts);
expect(policySettingsPatch(state, rules, ctx())).toEqual({ fontSize: 'large' });
expect(policySettingsPatch({ ...state, fontSize: 'small' }, rules, ctx(['fontSize']))).toEqual({});
expect(isLockedSetting(rules, 'fontSize')).toBe(false);
});
it('ignores values of the wrong type or outside the allowed values and bounds', () => {
const rules = settingsRulesFromPolicy(policy({
defaults: { density: 5, markAsReadDelay: 99999 },
restrictions: { markAsReadDelay: { max: 5000 }, theme: { allowedValues: ['light'] } },
themePolicy: { defaultColorMode: 'dark' },
}), opts);
expect(policySettingsPatch(state, rules, ctx())).toEqual({});
});
it('keeps toasts that stay until closed within a minimum', () => {
const rules = settingsRulesFromPolicy(policy({ defaults: { toastDuration: 0 }, restrictions: { toastDuration: { min: 10 } } }), opts);
expect(policySettingsPatch(state, rules, ctx())).toEqual({ toastDuration: 0 });
});
it('moves off a disabled theme', () => {
const rules = settingsRulesFromPolicy(policy({ themePolicy: { disabledBuiltinThemes: ['builtin-nord'] } }), opts);
expect(policySettingsPatch({ ...state, activeThemeId: 'builtin-nord' }, rules, ctx(['activeThemeId']))).toEqual({ activeThemeId: null });
});
});
describe('policyReleasePatch', () => {
it('puts back the default for what a policy no longer imposes, unless the user chose it', () => {
const before = settingsRulesFromPolicy(policy({ defaults: { density: 'compact' }, restrictions: { markAsReadDelay: { locked: true, value: 3000 } } }), opts);
const after = settingsRulesFromPolicy(policy({}), opts);
const state = { ...builtIn, density: 'compact', markAsReadDelay: 3000 };
expect(policyReleasePatch(state, before, after, ctx())).toEqual({ density: 'regular', markAsReadDelay: 0 });
expect(policyReleasePatch(state, before, after, ctx(['density']))).toEqual({ markAsReadDelay: 0 });
// A value the user changed since stays.
expect(policyReleasePatch({ ...state, density: 'comfortable' }, before, after, ctx())).toEqual({ markAsReadDelay: 0 });
});
});
describe('webmailLinksFromConfig and helpLinks', () => {
it('keeps http(s) and mailto links, drops the rest, and lists them in the webmail order', () => {
const links = webmailLinksFromConfig({
helpUrl: 'https://help.example.org/mail',
accessibilityStatementUrl: 'javascript:alert(1)',
accessibilityFeedbackUrl: 'mailto:a11y@example.org',
signLanguageUrl: 'https://dgs.example.org',
easyReadUrl: '',
});
expect(links).toEqual({
helpUrl: 'https://help.example.org/mail',
accessibilityFeedbackUrl: 'mailto:a11y@example.org',
signLanguageUrl: 'https://dgs.example.org',
});
expect(helpLinks(links).map((l) => l.id)).toEqual(['feedback', 'help', 'sign_language']);
expect(webmailLinksFromConfig(null)).toEqual({});
expect(helpLinks({})).toEqual([]);
});
});
+515
View File
@@ -0,0 +1,515 @@
/**
* The organisation policy of a Bulwark webmail (GET <webmail>/api/admin/policy,
* bulwarkmail.org/docs/development/mobile-handoff), and what it means for
* the app's settings. Pure functions: the policy store fetches and caches,
* the settings store applies what policySettingsPatch returns.
*
* Ported from the webmail:
* - lib/admin/types.ts (SettingsPolicy, FeatureGates, ThemePolicy)
* - lib/admin/policy-scopes.ts (resolvePolicy: domain and group scopes)
* - stores/settings-store.ts (adminPolicyPatch, adminPolicyReleasePatch):
* `defaults` fill settings the user hasn't chosen, a locked restriction
* pins its value whatever the user chose, and a value a policy stops
* imposing goes back to the build default
* - stores/policy-store.ts getColorModePolicy and stores/theme-store.ts:
* the theme policy's color mode and themes
*
* policy.json is written by hand, so everything read here is checked.
*/
export interface SettingRestriction {
locked?: boolean;
value?: unknown;
hidden?: boolean;
allowedValues?: unknown[];
min?: number;
max?: number;
}
export type ColorMode = 'light' | 'dark' | 'system';
export interface ThemePolicy {
disabledBuiltinThemes: string[];
disabledThemes: string[];
defaultThemeId: string | null;
defaultColorMode: ColorMode | null;
lockColorMode: boolean;
}
export interface PolicyScope {
id: string;
match: { domains?: string[]; groups?: string[] };
policy: {
features?: Record<string, boolean>;
restrictions?: Record<string, SettingRestriction>;
defaults?: Record<string, unknown>;
organisation?: Record<string, unknown>;
};
}
/** The parts of the webmail's SettingsPolicy the app uses. */
export interface OrgPolicy {
restrictions: Record<string, SettingRestriction>;
features: Record<string, boolean>;
defaults: Record<string, unknown>;
themePolicy: ThemePolicy;
forceEnabledThemes: string[];
/** Organisation settings (signatures, disclaimer...), kept as sent. */
organisation?: Record<string, unknown>;
/** Domain and group overrides; empty once resolved for an account. */
scopes: PolicyScope[];
}
/** Who the policy is resolved for: the account's address and its group accounts. */
export interface PolicySubject {
email: string;
groups: string[];
}
/**
* The webmail's built-in gate values (lib/admin/types.ts
* DEFAULT_FEATURE_GATES) for the gates the app reads. A policy without a
* gate gets this.
*/
export const DEFAULT_FEATURE_GATES = {
themesEnabled: true,
templatesEnabled: true,
customKeywordsEnabled: true,
calendarEnabled: true,
calendarTasksEnabled: true,
smimeEnabled: true,
externalContentEnabled: true,
debugModeEnabled: true,
filesEnabled: true,
contactsEnabled: true,
settingsExportEnabled: true,
sidebarAppsEnabled: true,
crossUnreadViewEnabled: false,
crossStarredViewEnabled: false,
crossAllViewEnabled: false,
unifiedCrossAccountEnabled: false,
customFromEnabled: true,
archiveActionEnabled: true,
structuredMailEnabled: true,
} as const;
export type FeatureGate = keyof typeof DEFAULT_FEATURE_GATES;
const DEFAULT_THEME_POLICY: ThemePolicy = {
disabledBuiltinThemes: [],
disabledThemes: [],
defaultThemeId: null,
defaultColorMode: null,
lockColorMode: false,
};
const COLOR_MODES: readonly ColorMode[] = ['light', 'dark', 'system'];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const isSafeKey = (key: string) => key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
}
function sanitizeRestriction(raw: unknown): SettingRestriction | null {
if (!isRecord(raw)) return null;
const out: SettingRestriction = {};
if (typeof raw.locked === 'boolean') out.locked = raw.locked;
if (typeof raw.hidden === 'boolean') out.hidden = raw.hidden;
if ('value' in raw) out.value = raw.value;
if (Array.isArray(raw.allowedValues)) out.allowedValues = raw.allowedValues.slice(0, 100);
if (typeof raw.min === 'number' && Number.isFinite(raw.min)) out.min = raw.min;
if (typeof raw.max === 'number' && Number.isFinite(raw.max)) out.max = raw.max;
return out;
}
function sanitizeRestrictions(raw: unknown): Record<string, SettingRestriction> {
const out: Record<string, SettingRestriction> = {};
if (!isRecord(raw)) return out;
for (const [key, value] of Object.entries(raw)) {
if (!isSafeKey(key)) continue;
const restriction = sanitizeRestriction(value);
if (restriction) out[key] = restriction;
}
return out;
}
function sanitizeFeatures(raw: unknown): Record<string, boolean> {
const out: Record<string, boolean> = {};
if (!isRecord(raw)) return out;
for (const [key, value] of Object.entries(raw)) {
if (isSafeKey(key) && typeof value === 'boolean') out[key] = value;
}
return out;
}
function sanitizeDefaults(raw: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (!isRecord(raw)) return out;
for (const [key, value] of Object.entries(raw)) {
if (!isSafeKey(key)) continue;
if (typeof value === 'boolean' || typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value))) {
out[key] = value;
}
}
return out;
}
function sanitizeThemePolicy(raw: unknown): ThemePolicy {
if (!isRecord(raw)) return { ...DEFAULT_THEME_POLICY };
return {
disabledBuiltinThemes: stringList(raw.disabledBuiltinThemes),
disabledThemes: stringList(raw.disabledThemes),
defaultThemeId: typeof raw.defaultThemeId === 'string' && raw.defaultThemeId ? raw.defaultThemeId : null,
defaultColorMode: COLOR_MODES.find((m) => m === raw.defaultColorMode) ?? null,
lockColorMode: raw.lockColorMode === true,
};
}
const lowerList = (value: unknown) => stringList(value).map((v) => v.trim().toLowerCase().replace(/\.+$/, '')).filter(Boolean);
function sanitizeScopes(raw: unknown): PolicyScope[] {
if (!Array.isArray(raw)) return [];
const out: PolicyScope[] = [];
const seen = new Set<string>();
for (const item of raw) {
if (!isRecord(item) || typeof item.id !== 'string' || seen.has(item.id)) continue;
seen.add(item.id);
const match = isRecord(item.match) ? item.match : {};
const policy = isRecord(item.policy) ? item.policy : {};
out.push({
id: item.id,
match: { domains: lowerList(match.domains), groups: lowerList(match.groups) },
policy: {
features: sanitizeFeatures(policy.features),
restrictions: sanitizeRestrictions(policy.restrictions),
defaults: sanitizeDefaults(policy.defaults),
...(isRecord(policy.organisation) ? { organisation: policy.organisation } : {}),
},
});
if (out.length >= 200) break;
}
return out;
}
/** The policy from the webmail's JSON, or null when it isn't one. */
export function sanitizePolicy(raw: unknown): OrgPolicy | null {
if (!isRecord(raw)) return null;
return {
restrictions: sanitizeRestrictions(raw.restrictions),
features: sanitizeFeatures(raw.features),
defaults: sanitizeDefaults(raw.defaults),
themePolicy: sanitizeThemePolicy(raw.themePolicy),
forceEnabledThemes: stringList(raw.forceEnabledThemes),
...(isRecord(raw.organisation) ? { organisation: raw.organisation } : {}),
scopes: sanitizeScopes(raw.scopes),
};
}
function domainOf(email: string): string {
const at = email.lastIndexOf('@');
return at >= 0 ? email.slice(at + 1).trim().toLowerCase().replace(/\.+$/, '') : '';
}
function domainPatternMatches(pattern: string, domain: string): boolean {
if (!domain) return false;
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(1);
return domain.endsWith(suffix) && domain.length > suffix.length;
}
return pattern === domain;
}
/**
* The effective policy for `subject` (webmail resolvePolicy): the global
* policy with each matching scope applied on top, domain matches first,
* then group matches, the later value winning per key. Organisation
* settings merge per section. No subject: only the global policy.
*/
export function resolvePolicy(policy: OrgPolicy, subject: PolicySubject | null): OrgPolicy {
let features = { ...policy.features };
let restrictions = { ...policy.restrictions };
let defaults = { ...policy.defaults };
let organisation = policy.organisation;
if (subject) {
const domain = domainOf(subject.email);
const groups = new Set(subject.groups.map((g) => g.trim().toLowerCase()));
const byDomain = policy.scopes.filter((s) => (s.match.domains ?? []).some((p) => domainPatternMatches(p, domain)));
const byGroup = policy.scopes.filter((s) => !byDomain.includes(s) && (s.match.groups ?? []).some((g) => groups.has(g)));
for (const scope of [...byDomain, ...byGroup]) {
features = { ...features, ...(scope.policy.features ?? {}) };
restrictions = { ...restrictions, ...(scope.policy.restrictions ?? {}) };
defaults = { ...defaults, ...(scope.policy.defaults ?? {}) };
if (scope.policy.organisation) {
const merged: Record<string, unknown> = { ...(organisation ?? {}) };
for (const [section, values] of Object.entries(scope.policy.organisation)) {
merged[section] = isRecord(values) && isRecord(merged[section])
? { ...(merged[section] as Record<string, unknown>), ...values }
: values;
}
organisation = merged;
}
}
}
return { ...policy, features, restrictions, defaults, ...(organisation ? { organisation } : {}), scopes: [] };
}
/** Whether a gate is on; without a policy (no webmail known) everything is. */
export function isFeatureEnabled(policy: OrgPolicy | null | undefined, feature: FeatureGate): boolean {
if (!policy) return true;
const value = policy.features[feature];
if (feature === 'crossAllViewEnabled' && value === undefined && typeof policy.features.allMailViewEnabled === 'boolean') {
// Folded into crossAllViewEnabled (webmail normalizes it forward).
return policy.features.allMailViewEnabled;
}
return typeof value === 'boolean' ? value : DEFAULT_FEATURE_GATES[feature];
}
/**
* What a policy does to the app's settings, by the app's setting keys.
* - defaults: values for settings the user hasn't chosen
* - locks: values pinned whatever the user chose
* - restrictions: allowed values and bounds a value must keep to
* - blocked: values a setting may not hold (a disabled theme)
* - hidden: settings not offered
*/
export interface SettingsRules {
defaults: Record<string, unknown>;
locks: Record<string, unknown>;
restrictions: Record<string, SettingRestriction>;
blocked: Record<string, unknown[]>;
hidden: string[];
}
export const EMPTY_SETTINGS_RULES: SettingsRules = { defaults: {}, locks: {}, restrictions: {}, blocked: {}, hidden: [] };
/**
* Gates that turn an app setting off: the webmail hides the option and the
* view it opts into, so the setting is pinned off here.
*/
const GATED_SETTINGS: ReadonlyArray<[FeatureGate, string]> = [
['calendarTasksEnabled', 'enableCalendarTasks'],
['debugModeEnabled', 'debugMode'],
['unifiedCrossAccountEnabled', 'unifiedCrossAccount'],
['crossUnreadViewEnabled', 'enableCrossUnreadView'],
['crossStarredViewEnabled', 'enableCrossStarredView'],
['crossAllViewEnabled', 'enableCrossAllView'],
];
const hasOwn = (obj: object, key: string) => Object.prototype.hasOwnProperty.call(obj, key);
/**
* The settings rules of an (effective) policy. `keyFor` maps a webmail
* setting key to the app's (null for one the app doesn't have);
* `themeIds` are the app's built-in theme ids.
*/
export function settingsRulesFromPolicy(
policy: OrgPolicy | null | undefined,
opts: { keyFor: (webmailKey: string) => string | null; themeIds: readonly string[] },
): SettingsRules {
if (!policy) return EMPTY_SETTINGS_RULES;
const rules: SettingsRules = { defaults: {}, locks: {}, restrictions: {}, blocked: {}, hidden: [] };
for (const [key, value] of Object.entries(policy.defaults)) {
const local = opts.keyFor(key);
if (local) rules.defaults[local] = value;
}
for (const [key, restriction] of Object.entries(policy.restrictions)) {
const local = opts.keyFor(key);
if (!local) continue;
rules.restrictions[local] = restriction;
if (restriction.hidden === true) rules.hidden.push(local);
if (restriction.locked !== true) continue;
const value = hasOwn(restriction, 'value') ? restriction.value : hasOwn(policy.defaults, key) ? policy.defaults[key] : undefined;
if (value !== undefined) rules.locks[local] = value;
}
for (const [gate, key] of GATED_SETTINGS) {
if (!isFeatureEnabled(policy, gate)) rules.locks[key] = false;
}
// Color mode (#1280): the default for users who haven't picked one, and
// for everyone while locked; a lock without a mode pins system.
const tp = policy.themePolicy;
if (tp.lockColorMode) rules.locks.theme = tp.defaultColorMode ?? 'system';
else if (tp.defaultColorMode) rules.defaults.theme = tp.defaultColorMode;
// Themes: forced > the user's choice > the policy default; a disabled one
// can't be kept. With themes turned off, the built-in default.
const known = new Set(opts.themeIds);
const disabled = tp.disabledBuiltinThemes.filter((id) => known.has(id));
if (disabled.length) rules.blocked.activeThemeId = disabled;
const forced = policy.forceEnabledThemes.find((id) => known.has(id));
if (!isFeatureEnabled(policy, 'themesEnabled')) rules.locks.activeThemeId = null;
else if (forced) rules.locks.activeThemeId = forced;
else if (tp.defaultThemeId && known.has(tp.defaultThemeId) && !disabled.includes(tp.defaultThemeId)) {
rules.defaults.activeThemeId = tp.defaultThemeId;
}
return rules;
}
/**
* Text size, contrast, motion and the other reading aids can't be locked
* (webmail lib/accessibility-settings.ts UNLOCKABLE_SETTING_KEYS): a lock on
* one of them works as a default the user may change.
*/
export const UNLOCKABLE_SETTING_KEYS: ReadonlySet<string> = new Set([
'fontSize', 'reduceMotion', 'contrast', 'reduceTransparency', 'underlineLinks', 'showColorNames', 'colorBlindPalette',
'appFont', 'lineSpacing', 'letterSpacing', 'wordSpacing', 'paragraphSpacing', 'readingWidth', 'readingMask',
'plainLanguage', 'pauseAnimatedImages', 'focusRing', 'focusAlwaysVisible', 'largeTargets', 'largeCursor',
'openOnHover', 'showRowActionButton', 'nativeScrollbars', 'mailViewMode', 'mailTextScale', 'mailMinFontSize',
'mailOverrideStyles', 'mailFitWidth', 'readAloudRate', 'readAloudVoice', 'visualAlerts', 'unreadCountStyle',
'doNotDisturb', 'announceNewMail', 'announceRowPreview', 'simpleMode', 'simpleModePagedList', 'showAccessibilityButton',
'plainTextFont', 'emailAlwaysLightMode', 'showToolbarLabels',
]);
export interface PolicyPatchContext {
/** The build defaults of the settings, by the app's keys. */
builtIn: Record<string, unknown>;
/** Settings the user chose themselves. */
explicit: ReadonlySet<string>;
/** Whether a value is one the setting can hold (type and validators). */
isValid: (key: string, value: unknown) => boolean;
}
/** The keys a lock holds: unlockable ones only while the user hasn't chosen. */
export function lockedKeys(rules: SettingsRules, explicit: ReadonlySet<string>): string[] {
return Object.keys(rules.locks).filter((key) => !(UNLOCKABLE_SETTING_KEYS.has(key) && explicit.has(key)));
}
/** Whether the user may not change `key`: locked, and not one of the reading aids. */
export function isLockedSetting(rules: SettingsRules, key: string): boolean {
return hasOwn(rules.locks, key) && !UNLOCKABLE_SETTING_KEYS.has(key);
}
function allowedBy(key: string, value: unknown, rules: SettingsRules, ctx: PolicyPatchContext): boolean {
if (!hasOwn(ctx.builtIn, key) || !ctx.isValid(key, value)) return false;
return policyAllowsValue(key, value, rules);
}
/**
* Whether the policy lets `key` hold `value`: not a blocked value, inside
* the restriction's allowed values and bounds. Says nothing about locks.
*/
export function policyAllowsValue(key: string, value: unknown, rules: SettingsRules): boolean {
if ((rules.blocked[key] ?? []).includes(value)) return false;
const restriction = rules.restrictions[key];
if (!restriction) return true;
if (Array.isArray(restriction.allowedValues) && restriction.allowedValues.length > 0 && !restriction.allowedValues.includes(value)) {
return false;
}
if (typeof value === 'number') {
// toastDuration: 0 keeps toasts until closed, which meets any minimum;
// -1 (automatic) is 5 s.
const effective = key === 'toastDuration' ? (value === 0 ? Infinity : value === -1 ? 5 : value) : value;
if (typeof restriction.min === 'number' && effective < restriction.min) return false;
if (typeof restriction.max === 'number' && effective > restriction.max) return false;
}
return true;
}
/**
* What the policy changes in `state` (webmail adminPolicyPatch): each
* default for a setting the user hasn't chosen, then each lock whatever
* they chose, and a value the policy no longer allows (a disabled theme)
* back to the default.
*/
export function policySettingsPatch(
state: Record<string, unknown>,
rules: SettingsRules,
ctx: PolicyPatchContext,
): Record<string, unknown> {
const patch: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rules.defaults)) {
if (ctx.explicit.has(key) || !allowedBy(key, value, rules, ctx)) continue;
if (state[key] !== value) patch[key] = value;
}
for (const [key, blocked] of Object.entries(rules.blocked)) {
const current = hasOwn(patch, key) ? patch[key] : state[key];
if (!blocked.includes(current) || !hasOwn(ctx.builtIn, key)) continue;
const fallback = hasOwn(rules.defaults, key) && allowedBy(key, rules.defaults[key], rules, ctx) ? rules.defaults[key] : ctx.builtIn[key];
patch[key] = fallback;
}
for (const key of lockedKeys(rules, ctx.explicit)) {
const value = rules.locks[key];
if (!allowedBy(key, value, rules, ctx)) continue;
if (state[key] !== value) patch[key] = value;
else delete patch[key];
}
return patch;
}
function imposed(rules: SettingsRules): Map<string, unknown> {
const out = new Map<string, unknown>(Object.entries(rules.defaults));
for (const [key, value] of Object.entries(rules.locks)) out.set(key, value);
return out;
}
/**
* What goes back to the build default when a policy stops imposing a value
* (webmail adminPolicyReleasePatch): another account's policy, or the admin
* removed it. Only for settings the user hasn't chosen that still hold the
* imposed value.
*/
export function policyReleasePatch(
state: Record<string, unknown>,
previous: SettingsRules,
next: SettingsRules,
ctx: Pick<PolicyPatchContext, 'builtIn' | 'explicit'>,
): Record<string, unknown> {
const before = imposed(previous);
const after = imposed(next);
const patch: Record<string, unknown> = {};
for (const [key, value] of before) {
if (after.has(key) || ctx.explicit.has(key) || !hasOwn(ctx.builtIn, key)) continue;
if (state[key] === value && value !== ctx.builtIn[key]) patch[key] = ctx.builtIn[key];
}
return patch;
}
/** Operator links from the webmail's /api/config (per domain there). */
export interface WebmailLinks {
accessibilityStatementUrl?: string;
accessibilityFeedbackUrl?: string;
helpUrl?: string;
easyReadUrl?: string;
signLanguageUrl?: string;
}
/** The accessibility and help links, in the webmail's fixed order (lib/help-links.ts). */
export const HELP_LINK_ORDER: ReadonlyArray<[HelpLinkId, keyof WebmailLinks]> = [
['statement', 'accessibilityStatementUrl'],
['feedback', 'accessibilityFeedbackUrl'],
['help', 'helpUrl'],
['easy_read', 'easyReadUrl'],
['sign_language', 'signLanguageUrl'],
];
export type HelpLinkId = 'statement' | 'feedback' | 'help' | 'easy_read' | 'sign_language';
const LINK_RE = /^(https?:\/\/|mailto:)\S+$/i;
/** The links of a /api/config answer: http(s) and mailto: only. */
export function webmailLinksFromConfig(raw: unknown): WebmailLinks {
if (!isRecord(raw)) return {};
const out: WebmailLinks = {};
for (const [, key] of HELP_LINK_ORDER) {
const value = raw[key];
if (typeof value === 'string' && LINK_RE.test(value.trim())) out[key] = value.trim();
}
return out;
}
/** The configured links in their order (webmail helpLinksFrom); empty when none are set. */
export function helpLinks(links: WebmailLinks): { id: HelpLinkId; href: string }[] {
const out: { id: HelpLinkId; href: string }[] = [];
for (const [id, key] of HELP_LINK_ORDER) {
const href = links[key]?.trim() ?? '';
if (LINK_RE.test(href)) out.push({ id, href });
}
return out;
}
@@ -145,11 +145,11 @@ describe('loginViaPairing with an app password', () => {
useAuthStore.setState({ login }); useAuthStore.setState({ login });
try { try {
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true }); await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true });
expect(login).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', { addAccount: true }); expect(login).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', { addAccount: true, webmailUrl: WEBMAIL });
redeem.mockResolvedValueOnce(passwordResult); redeem.mockResolvedValueOnce(passwordResult);
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode()); await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
expect(login).toHaveBeenLastCalledWith(SERVER, 'ada@example.com', 'app-pass-123', { addAccount: undefined }); expect(login).toHaveBeenLastCalledWith(SERVER, 'ada@example.com', 'app-pass-123', { addAccount: undefined, webmailUrl: WEBMAIL });
} finally { } finally {
useAuthStore.setState({ login: original }); useAuthStore.setState({ login: original });
} }
@@ -177,7 +177,7 @@ describe('loginViaPairing with an app password', () => {
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, newCode())).rejects.toBe(totp); await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, newCode())).rejects.toBe(totp);
expect(useAuthStore.getState().pendingTotpLogin).toEqual({ expect(useAuthStore.getState().pendingTotpLogin).toEqual({
serverUrl: SERVER, username: 'ada@example.com', password: 'app-pass-123', serverUrl: SERVER, username: 'ada@example.com', password: 'app-pass-123', webmailUrl: WEBMAIL,
}); });
expect(useAuthStore.getState().isLoading).toBe(false); expect(useAuthStore.getState().isLoading).toBe(false);
}); });
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// The webmail an account signed in through (QR pairing, the browser
// hand-off) is kept on the account: its organisation policy is read from
// there. A sign-in straight to the mail server has none.
const request = vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
methodResponses: calls.map(([name, , id]) => {
if (name === 'Identity/get') return [name, { list: [{ id: 'i1', name: 'Ada', email: 'ada@example.com' }] }, id];
if (name === 'Mailbox/get') return [name, { list: [{ id: 'mb-in', name: 'Inbox', role: 'inbox' }], state: 'mbs-1' }, id];
return ['error', { type: 'forbidden' }, id];
}),
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
connect: vi.fn(async () => ({ apiUrl: 'https://mail.example.com/jmap/' })),
connectWithOAuth: vi.fn(async () => ({
session: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'ada@example.com',
accountId: 'ada@example.com@mail.example.com',
})),
snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })),
restoreSnapshot: vi.fn(),
onAuthFailure: vi.fn(() => () => undefined),
onTokenRefresh: vi.fn(() => () => undefined),
hasAccountCapability: vi.fn(() => false),
getAccountName: () => undefined,
getSharedMailAccounts: () => [],
request: (calls: Array<[string, Record<string, unknown>, string]>) => request(calls),
accountId: 'acc-1',
isConnected: true,
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'ada@example.com',
serverUrl: 'https://mail.example.com',
},
AuthenticationError: class AuthenticationError extends Error {},
NetworkError: class NetworkError extends Error {},
}));
vi.mock('../../lib/push-notifications', () => ({
teardownPushNotifications: vi.fn(async () => undefined),
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
vi.mock('../../lib/oauth', async (importOriginal) => ({
...(await importOriginal<typeof import('../../lib/oauth')>()),
redeemPairingCode: vi.fn(),
runWebmailHandoff: vi.fn(),
}));
vi.mock('../../lib/oauth-native', async (importOriginal) => ({
...(await importOriginal<typeof import('../../lib/oauth-native')>()),
probeWebmail: vi.fn(async () => true),
}));
import { redeemPairingCode, runWebmailHandoff, type HandoffResult } from '../../lib/oauth';
import { useAuthStore } from '../auth-store';
import { useAccountStore } from '../account-store';
const WEBMAIL = 'https://webmail.example.org/mail/';
const SERVER = 'https://mail.example.com';
const redeem = redeemPairingCode as ReturnType<typeof vi.fn>;
const handoff = runWebmailHandoff as ReturnType<typeof vi.fn>;
const oauthResult: HandoffResult = {
flow: 'oauth',
serverUrl: SERVER,
tokens: { accessToken: 'at', refreshToken: 'rt', tokenEndpoint: `${SERVER}/auth/token`, clientId: 'bulwark-webmail', source: 'handoff' },
};
const passwordResult: HandoffResult = { flow: 'password', serverUrl: SERVER, username: 'ada@example.com', password: 'pw' };
let codeCounter = 0;
const newCode = () => (codeCounter += 1).toString(16).padStart(64, 'b');
const onlyAccount = () => {
const { accounts } = useAccountStore.getState();
expect(accounts).toHaveLength(1);
return accounts[0];
};
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ isAuthenticated: false, isLoading: false, error: null, session: null, pendingTotpLogin: null });
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
});
describe('the webmail an account came through', () => {
it('is kept for a pairing with an OAuth grant', async () => {
redeem.mockResolvedValueOnce(oauthResult);
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
expect(onlyAccount().webmailUrl).toBe('https://webmail.example.org/mail');
});
it('is kept for a pairing with an app password', async () => {
redeem.mockResolvedValueOnce(passwordResult);
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
expect(onlyAccount().webmailUrl).toBe('https://webmail.example.org/mail');
});
it('is kept for a browser hand-off, both flows', async () => {
handoff.mockResolvedValueOnce(oauthResult);
await useAuthStore.getState().loginViaWebmail(WEBMAIL);
expect(onlyAccount().webmailUrl).toBe('https://webmail.example.org/mail');
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
handoff.mockResolvedValueOnce(passwordResult);
await useAuthStore.getState().loginViaWebmail(WEBMAIL);
expect(onlyAccount().webmailUrl).toBe('https://webmail.example.org/mail');
});
it('is kept when the hand-off password needs a second factor first', async () => {
const { jmapClient } = await import('../../api/jmap-client');
const totp = new Error('TOTP required');
totp.name = 'TotpRequiredError';
(jmapClient.connect as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(totp);
handoff.mockResolvedValueOnce(passwordResult);
await expect(useAuthStore.getState().loginViaWebmail(WEBMAIL)).rejects.toBe(totp);
expect(useAccountStore.getState().accounts).toHaveLength(0);
// The login screen asks for the code and signs in again with it.
await useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { totp: '123456' });
expect(onlyAccount().webmailUrl).toBe('https://webmail.example.org/mail');
});
it('is not invented for a sign-in straight to the mail server', async () => {
await useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw');
expect(onlyAccount().webmailUrl).toBeUndefined();
});
it('stays on the account when it signs in again directly', async () => {
redeem.mockResolvedValueOnce(passwordResult);
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
await useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw');
expect(onlyAccount().webmailUrl).toBe('https://webmail.example.org/mail');
});
});
+207
View File
@@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// The organisation policy, read from the webmail each account signed in
// through (webmail f74ff49f5: Bearer token, X-JMAP-Server-Url), cached per
// account and applied to the settings.
const { client, mockFetch } = vi.hoisted(() => ({
client: {
serverUrl: 'https://mail.example.com' as string | null,
username: 'ada@example.com' as string | null,
usesBearerAuth: true,
authHeader: 'Bearer test-token',
currentSession: null as unknown,
ensureFreshToken: vi.fn(async () => undefined),
},
mockFetch: vi.fn(),
}));
vi.mock('../../api/jmap-client', () => ({ jmapClient: client }));
vi.mock('../../lib/client-cert', () => ({ fetchWithoutForeignRedirect: mockFetch, secureFetch: mockFetch }));
vi.mock('../auth-store', async () => {
const { create } = await import('zustand');
return { useAuthStore: create(() => ({ activeAccountId: null as string | null, session: null as unknown })) };
});
import { useAuthStore } from '../auth-store';
import { useAccountStore, type AccountEntry } from '../account-store';
import { useSettingsStore, resetPolicyRulesForTests } from '../settings-store';
import { useToastStore } from '../toast-store';
import { startPolicySync, usePolicyStore, settingKeyFor } from '../policy-store';
const WEBMAIL = 'https://webmail.example.org';
const ADA = 'ada@example.com@mail.example.com';
const BOB = 'bob@other.org@mail.other.org';
function account(id: string, username: string, serverUrl: string, webmailUrl?: string): AccountEntry {
return {
id, serverUrl, username, displayName: username, email: username, avatarColor: '#000',
lastLoginAt: 1, isConnected: true, hasError: false, isDefault: false,
...(webmailUrl ? { webmailUrl } : {}),
};
}
let policies: Record<string, unknown>;
let configs: Record<string, unknown>;
let publicScope: boolean;
function response(url: string, data: unknown, headers: Record<string, string> = {}): Response {
return {
ok: true, status: 200, url, redirected: false,
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
json: async () => data,
} as unknown as Response;
}
beforeEach(() => {
vi.clearAllMocks();
resetPolicyRulesForTests();
client.serverUrl = 'https://mail.example.com';
client.username = 'ada@example.com';
client.usesBearerAuth = true;
client.currentSession = null;
publicScope = false;
policies = {
[WEBMAIL]: { restrictions: { density: { locked: true, value: 'compact' } }, features: {}, defaults: { markAsReadDelay: 2000 }, themePolicy: {} },
'https://webmail.other.org': { restrictions: {}, features: { calendarEnabled: false }, defaults: {}, themePolicy: {} },
};
configs = { [WEBMAIL]: { helpUrl: 'https://help.example.org' } };
mockFetch.mockImplementation(async (url: string) => {
const m = /^(https?:\/\/[^/]+)\/api\/(admin\/policy|config)$/.exec(url);
if (!m) throw new Error(`Unexpected fetch: ${url}`);
if (m[2] === 'config') return response(url, configs[m[1]] ?? {});
return response(url, policies[m[1]], publicScope ? { 'x-bulwark-policy-scope': 'public' } : {});
});
useAccountStore.setState({
accounts: [
account(ADA, 'ada@example.com', 'https://mail.example.com', WEBMAIL),
account(BOB, 'bob@other.org', 'https://mail.other.org', 'https://webmail.other.org'),
],
activeAccountId: ADA,
defaultAccountId: ADA,
});
useAuthStore.setState({ activeAccountId: ADA, session: null });
usePolicyStore.setState({ byAccount: {}, accountId: null, effective: null, links: {} });
useSettingsStore.setState({ density: 'regular', markAsReadDelay: 0, explicitSettings: [], hydrated: true });
useToastStore.setState({ toasts: [] });
});
afterEach(() => resetPolicyRulesForTests());
describe('fetching the policy', () => {
it('sends the access token and the account server, and applies the answer', async () => {
await usePolicyStore.getState().refresh(ADA);
const call = mockFetch.mock.calls.find(([url]) => url === `${WEBMAIL}/api/admin/policy`)!;
expect(call[1].headers).toEqual({
Accept: 'application/json',
Authorization: 'Bearer test-token',
'X-JMAP-Server-Url': 'https://mail.example.com',
});
expect(client.ensureFreshToken).toHaveBeenCalled();
expect(useSettingsStore.getState()).toMatchObject({ density: 'compact', markAsReadDelay: 2000 });
expect(usePolicyStore.getState().links).toEqual({ helpUrl: 'https://help.example.org' });
});
it('asks without a token for a password account and keeps that it got the public part', async () => {
client.usesBearerAuth = false;
publicScope = true;
await usePolicyStore.getState().refresh(ADA);
const call = mockFetch.mock.calls.find(([url]) => url === `${WEBMAIL}/api/admin/policy`)!;
expect(call[1].headers).toEqual({ Accept: 'application/json' });
expect(usePolicyStore.getState().byAccount[ADA].partial).toBe(true);
});
it('never sends the token to a plain-http webmail', async () => {
useAccountStore.getState().updateAccount(ADA, { webmailUrl: 'http://webmail.example.org' });
policies['http://webmail.example.org'] = policies[WEBMAIL];
await usePolicyStore.getState().refresh(ADA);
const call = mockFetch.mock.calls.find(([url]) => url === 'http://webmail.example.org/api/admin/policy')!;
expect(call[1].headers.Authorization).toBeUndefined();
});
it('does nothing for an account without a webmail', async () => {
useAccountStore.getState().updateAccount(ADA, { webmailUrl: undefined });
await usePolicyStore.getState().refresh(ADA);
expect(mockFetch).not.toHaveBeenCalled();
expect(useSettingsStore.getState().density).toBe('regular');
});
it('keeps the cached policy when the webmail cannot be reached', async () => {
await usePolicyStore.getState().refresh(ADA);
mockFetch.mockRejectedValue(new Error('Network request failed'));
await usePolicyStore.getState().refresh(ADA);
// An offline start applies it from the cache.
resetPolicyRulesForTests();
useSettingsStore.setState({ density: 'regular' });
usePolicyStore.getState().activate(ADA);
expect(useSettingsStore.getState().density).toBe('compact');
});
});
describe('applying it', () => {
it('refuses a change to a locked setting and says why', async () => {
const stop = startPolicySync();
await usePolicyStore.getState().refresh(ADA);
useSettingsStore.getState().updateSetting('density', 'comfortable');
expect(useSettingsStore.getState().density).toBe('compact');
expect(useToastStore.getState().toasts.map((t) => t.title)).toEqual(['Set by your organisation']);
// A setting the policy only defaults stays the user's to change.
useSettingsStore.getState().updateSetting('markAsReadDelay', 0);
expect(useSettingsStore.getState().markAsReadDelay).toBe(0);
stop();
});
it('follows the account switch: the old policy lets go, the new one applies', async () => {
await usePolicyStore.getState().refresh(ADA);
await usePolicyStore.getState().refresh(BOB);
expect(useSettingsStore.getState().density).toBe('compact');
useAuthStore.setState({ activeAccountId: BOB });
usePolicyStore.getState().activate(BOB);
expect(useSettingsStore.getState()).toMatchObject({ density: 'regular', markAsReadDelay: 0 });
expect(usePolicyStore.getState().effective?.features.calendarEnabled).toBe(false);
});
it('ignores a cached policy from another webmail than the account has now', async () => {
await usePolicyStore.getState().refresh(ADA);
useAccountStore.getState().updateAccount(ADA, { webmailUrl: 'https://elsewhere.example.org' });
usePolicyStore.getState().activate(ADA);
expect(usePolicyStore.getState().effective).toBeNull();
expect(useSettingsStore.getState().density).toBe('regular');
});
});
describe('startPolicySync', () => {
it('fetches once the session is up, and again after a switch', async () => {
const stop = startPolicySync();
expect(mockFetch).not.toHaveBeenCalled();
useAuthStore.setState({ session: { apiUrl: 'x' } as never });
await vi.waitFor(() => expect(usePolicyStore.getState().byAccount[ADA]).toBeDefined());
client.serverUrl = 'https://mail.other.org';
client.username = 'bob@other.org';
useAuthStore.setState({ activeAccountId: BOB, session: { apiUrl: 'y' } as never });
await vi.waitFor(() => expect(usePolicyStore.getState().byAccount[BOB]).toBeDefined());
expect(usePolicyStore.getState().accountId).toBe(BOB);
stop();
});
it('drops the cache of a removed account', async () => {
const stop = startPolicySync();
await usePolicyStore.getState().refresh(BOB);
useAccountStore.getState().removeAccount(BOB);
expect(usePolicyStore.getState().byAccount[BOB]).toBeUndefined();
stop();
});
});
describe('settingKeyFor', () => {
it('maps the webmail names of settings to the app keys', () => {
expect(settingKeyFor('firstDayOfWeek')).toBe('calendarFirstDayOfWeek');
expect(settingKeyFor('density')).toBe('density');
expect(settingKeyFor('calendarFirstDayOfWeek')).toBeNull();
expect(settingKeyFor('trustedSenders')).toBeNull();
expect(settingKeyFor('noSuchSetting')).toBeNull();
});
});
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
useSettingsStore,
resetPolicyRulesForTests,
setPolicyLockedWriteListener,
isSettingLockedByPolicy,
legacyExplicitSettings,
} from '../settings-store';
import type { SettingsRules } from '../../lib/org-policy';
// The settings side of the organisation policy (webmail #1248): defaults for
// what the user hasn't chosen, locks whatever they chose, and a policy that
// arrives before the stored settings were read.
const rules = (r: Partial<SettingsRules>): SettingsRules => ({ defaults: {}, locks: {}, restrictions: {}, blocked: {}, hidden: [], ...r });
beforeEach(async () => {
resetPolicyRulesForTests();
await AsyncStorage.clear();
useSettingsStore.setState({ hydrated: false });
useSettingsStore.getState().resetToDefaults();
useSettingsStore.setState({ hydrated: false });
});
describe('explicitSettings', () => {
it('marks a setting the user changes, and only plain values', () => {
useSettingsStore.setState({ hydrated: true });
useSettingsStore.getState().updateSetting('density', 'compact');
useSettingsStore.getState().setTheme('dark');
useSettingsStore.getState().updateSetting('trustedSenders', ['a@example.org']);
expect(useSettingsStore.getState().explicitSettings).toEqual(['density', 'theme']);
});
it('counts values other than the default as chosen in settings stored before it existed', () => {
expect(legacyExplicitSettings({ density: 'compact', theme: 'system', trustedSenders: ['x'] })).toEqual(['density']);
});
it('works that out on hydrate, so a policy default does not replace an old choice', async () => {
await AsyncStorage.setItem('webmail:settings:v1', JSON.stringify({ density: 'compact' }));
useSettingsStore.getState().applyPolicyRules(rules({ defaults: { density: 'comfortable', markAsReadDelay: 2000 } }));
await useSettingsStore.getState().hydrate();
expect(useSettingsStore.getState()).toMatchObject({ density: 'compact', markAsReadDelay: 2000, explicitSettings: ['density'] });
});
});
describe('applyPolicyRules', () => {
it('does not write the defaults over stored settings it has not read yet', async () => {
await AsyncStorage.setItem('webmail:settings:v1', JSON.stringify({ theme: 'dark', explicitSettings: ['theme'] }));
useSettingsStore.getState().applyPolicyRules(rules({ locks: { density: 'compact' } }));
expect(JSON.parse((await AsyncStorage.getItem('webmail:settings:v1'))!)).toEqual({ theme: 'dark', explicitSettings: ['theme'] });
await useSettingsStore.getState().hydrate();
expect(useSettingsStore.getState()).toMatchObject({ theme: 'dark', density: 'compact' });
});
it('refuses changes to a locked setting and to a blocked value', () => {
useSettingsStore.setState({ hydrated: true });
const refused = vi.fn();
setPolicyLockedWriteListener(refused);
useSettingsStore.getState().applyPolicyRules(rules({ locks: { density: 'compact' }, blocked: { activeThemeId: ['builtin-nord'] } }));
expect(isSettingLockedByPolicy('density')).toBe(true);
useSettingsStore.getState().setDensity('comfortable');
useSettingsStore.getState().updateSetting('activeThemeId', 'builtin-nord');
expect(useSettingsStore.getState()).toMatchObject({ density: 'compact', activeThemeId: null });
expect(refused.mock.calls.map(([key]) => key)).toEqual(['density', 'activeThemeId']);
});
it('keeps locks over a reset and an import', () => {
useSettingsStore.setState({ hydrated: true });
useSettingsStore.getState().applyPolicyRules(rules({ locks: { density: 'compact' }, defaults: { markAsReadDelay: 2000 } }));
useSettingsStore.getState().resetToDefaults();
expect(useSettingsStore.getState()).toMatchObject({ density: 'compact', markAsReadDelay: 2000, explicitSettings: [] });
expect(useSettingsStore.getState().importSettings(JSON.stringify({ density: 'comfortable', markAsReadDelay: 0, dateFormat: 'full' }))).toBe(true);
// The file chose dateFormat; markAsReadDelay at its default counts as not chosen.
expect(useSettingsStore.getState()).toMatchObject({ density: 'compact', markAsReadDelay: 2000, dateFormat: 'full' });
});
it('lets go of what the next rules no longer impose', () => {
useSettingsStore.setState({ hydrated: true });
useSettingsStore.getState().applyPolicyRules(rules({ locks: { density: 'compact' }, defaults: { theme: 'dark' } }));
expect(useSettingsStore.getState()).toMatchObject({ density: 'compact', theme: 'dark' });
useSettingsStore.getState().applyPolicyRules(rules({}));
expect(useSettingsStore.getState()).toMatchObject({ density: 'regular', theme: 'system' });
useSettingsStore.getState().setDensity('comfortable');
expect(useSettingsStore.getState().density).toBe('comfortable');
});
});
+6
View File
@@ -23,6 +23,10 @@ export interface AccountEntry {
// JMAP primary account id (from the session's primaryAccounts). Recorded // JMAP primary account id (from the session's primaryAccounts). Recorded
// when push is set up so a relay payload can be routed to this entry. // when push is set up so a relay payload can be routed to this entry.
jmapAccountId?: string; jmapAccountId?: string;
// The Bulwark webmail the account signed in through (QR pairing or the
// browser hand-off), as an origin. The organisation policy and the
// operator's links are read from it; unknown for a manual server sign-in.
webmailUrl?: string;
} }
interface AccountState { interface AccountState {
@@ -67,6 +71,8 @@ export const useAccountStore = create<AccountState>()(
serverUrl: entry.serverUrl || a.serverUrl, serverUrl: entry.serverUrl || a.serverUrl,
displayName: entry.displayName || a.displayName, displayName: entry.displayName || a.displayName,
email: entry.email || a.email, email: entry.email || a.email,
// A manual sign-in knows no webmail: keep the one on file.
webmailUrl: entry.webmailUrl || a.webmailUrl,
lastLoginAt: entry.lastLoginAt, lastLoginAt: entry.lastLoginAt,
isConnected: entry.isConnected, isConnected: entry.isConnected,
hasError: entry.hasError, hasError: entry.hasError,
+19 -6
View File
@@ -108,7 +108,7 @@ export interface AuthState {
* login screen re-runs it with a code via `login(..., { totp })`. Cleared * login screen re-runs it with a code via `login(..., { totp })`. Cleared
* on the next successful sign-in. * on the next successful sign-in.
*/ */
pendingTotpLogin: { serverUrl: string; username: string; password: string } | null; pendingTotpLogin: { serverUrl: string; username: string; password: string; webmailUrl?: string } | null;
/** The account to sign in to again, set when its stored sign-in was refused. */ /** The account to sign in to again, set when its stored sign-in was refused. */
reauth: ReauthTarget | null; reauth: ReauthTarget | null;
@@ -336,6 +336,11 @@ async function switchesSettled(): Promise<void> {
export interface SignInOptions { export interface SignInOptions {
addAccount?: boolean; addAccount?: boolean;
signal?: AbortSignal; signal?: AbortSignal;
/**
* The Bulwark webmail the sign-in came through (browser hand-off or QR
* pairing), kept on the account for its organisation policy.
*/
webmailUrl?: string;
} }
interface SignedIn { interface SignedIn {
@@ -421,6 +426,7 @@ async function signIn(
lastLoginAt: Date.now(), lastLoginAt: Date.now(),
isConnected: true, isConnected: true,
hasError: false, hasError: false,
...(opts?.webmailUrl ? { webmailUrl: opts.webmailUrl.replace(/\/+$/, '') } : {}),
}); });
} catch (err) { } catch (err) {
// A cancelled connect may already hold the client: take it back. // A cancelled connect may already hold the client: take it back.
@@ -642,6 +648,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// An address on an internationalized domain signs in with the domain's // An address on an internationalized domain signs in with the domain's
// ASCII form, the one Stalwart stores (#1100); the local part stays. // ASCII form, the one Stalwart stores (#1100); the local part stays.
const username = toAsciiEmail(typedUsername); const username = toAsciiEmail(typedUsername);
// The code step after a hand-off's password: the account still came
// through that webmail.
const pending = get().pendingTotpLogin;
if (!opts?.webmailUrl && opts?.totp && pending?.webmailUrl
&& pending.username === username && pending.serverUrl === serverUrl.replace(/\/+$/, '')) {
opts = { ...opts, webmailUrl: pending.webmailUrl };
}
try { try {
// A new account with no room left fails here, before connect swaps // A new account with no room left fails here, before connect swaps
// the live client over or stores credentials for it. // the live client over or stores credentials for it.
@@ -662,7 +675,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (err instanceof Error && err.name === 'TotpRequiredError') { if (err instanceof Error && err.name === 'TotpRequiredError') {
// Keep what the user (or the webmail hand-off) supplied so the code // Keep what the user (or the webmail hand-off) supplied so the code
// step doesn't make them retype the password. // step doesn't make them retype the password.
set({ pendingTotpLogin: { serverUrl: serverUrl.replace(/\/+$/, ''), username, password } }); set({ pendingTotpLogin: { serverUrl: serverUrl.replace(/\/+$/, ''), username, password, webmailUrl: opts?.webmailUrl } });
} }
const message = err instanceof Error && err.name === 'TotpRequiredError' const message = err instanceof Error && err.name === 'TotpRequiredError'
? 'Two-factor code required' ? 'Two-factor code required'
@@ -734,7 +747,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Hand the credentials to the existing password login path so account // Hand the credentials to the existing password login path so account
// registration + feature-store wiring all behave identically to a // registration + feature-store wiring all behave identically to a
// manual sign-in. // manual sign-in.
await get().login(result.serverUrl, result.username, result.password, opts); await get().login(result.serverUrl, result.username, result.password, { ...opts, webmailUrl });
return; return;
} }
@@ -742,7 +755,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// token bundle. Bootstrap the JMAP session with Bearer auth and let // token bundle. Bootstrap the JMAP session with Bearer auth and let
// ensure/forceRefreshToken keep it alive going forward. // ensure/forceRefreshToken keep it alive going forward.
try { try {
await completeOAuthHandoff(set, get, result, opts); await completeOAuthHandoff(set, get, result, { ...opts, webmailUrl });
} catch (err) { } catch (err) {
set({ isLoading: false, error: oauthSignInMessage(err) }); set({ isLoading: false, error: oauthSignInMessage(err) });
throw err; throw err;
@@ -838,7 +851,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// password): the normal password sign-in, which also keeps the live // password): the normal password sign-in, which also keeps the live
// account when adding another one fails. // account when adding another one fails.
try { try {
await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount, signal: opts?.signal }); await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount, signal: opts?.signal, webmailUrl });
} catch (err) { } catch (err) {
throw connectFailed(err); throw connectFailed(err);
} }
@@ -846,7 +859,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} }
try { try {
await completeOAuthHandoff(set, get, result, opts); await completeOAuthHandoff(set, get, result, { ...opts, webmailUrl });
} catch (err) { } catch (err) {
throw connectFailed(err); throw connectFailed(err);
} }
+341
View File
@@ -0,0 +1,341 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createPersistStorage } from './persist-storage';
import { useAccountStore, type AccountEntry } from './account-store';
import { useAuthStore } from './auth-store';
import {
SETTINGS_KEY_MAP,
isPolicySettingKey,
setPolicyLockedWriteListener,
useSettingsStore,
} from './settings-store';
import { useToastStore } from './toast-store';
import { useLocaleStore } from './locale-store';
import { jmapClient } from '../api/jmap-client';
import { fetchWithoutForeignRedirect } from '../lib/client-cert';
import { isLoopbackHttp } from '../lib/oauth';
import {
EMPTY_SETTINGS_RULES,
isFeatureEnabled,
isLockedSetting,
resolvePolicy,
sanitizePolicy,
settingsRulesFromPolicy,
webmailLinksFromConfig,
type FeatureGate,
type OrgPolicy,
type PolicySubject,
type SettingsRules,
type WebmailLinks,
} from '../lib/org-policy';
import { BUILTIN_THEMES } from '../theme/builtin-themes';
// The organisation policy of the webmail each account signed in through
// (webmail stores/policy-store.ts, read with the account's access token:
// bulwarkmail.org/docs/development/mobile-handoff). The last answer is kept
// per account, so a start without network still applies it. Accounts
// signed in to a mail server directly have no webmail and no policy.
export const POLICY_FETCH_TIMEOUT_MS = 10_000;
/** Header the webmail sets on the public subset (lib/admin/types.ts). */
const POLICY_SCOPE_HEADER = 'x-bulwark-policy-scope';
export interface CachedPolicy {
/** The policy as fetched, scopes included. */
policy: OrgPolicy;
/** The webmail answered with the public subset (no token, or a refused one). */
partial: boolean;
fetchedAt: number;
/** The webmail it came from; an account that moved to another drops it. */
webmailUrl: string;
links: WebmailLinks;
}
interface PolicyState {
byAccount: Record<string, CachedPolicy>;
/** The account `effective` was resolved for. */
accountId: string | null;
/** The active account's policy with its scopes applied; null without a webmail. */
effective: OrgPolicy | null;
/** What `effective` does to the settings. */
rules: SettingsRules;
links: WebmailLinks;
/** Resolve and apply the cached policy of this account (the active one). */
activate: (accountId: string | null) => void;
/** Fetch the account's policy from its webmail, cache it, and apply it when it is the active one. */
refresh: (accountId: string) => Promise<void>;
/** Drop the cached policies of accounts no longer in the registry. */
prune: (accountIds: readonly string[]) => void;
}
const reverseKeyMap = new Map<string, string>(
Object.entries(SETTINGS_KEY_MAP).map(([local, web]) => [web as string, local]),
);
/** The app's setting key for a webmail setting key, or null when the app doesn't have it. */
export function settingKeyFor(webmailKey: string): string | null {
const mapped = reverseKeyMap.get(webmailKey);
if (mapped) return mapped;
// An app key the webmail names differently isn't the webmail's key.
if (Object.prototype.hasOwnProperty.call(SETTINGS_KEY_MAP, webmailKey)) return null;
return isPolicySettingKey(webmailKey) ? webmailKey : null;
}
const THEME_IDS = BUILTIN_THEMES.map((theme) => theme.id);
/** The settings rules of an effective policy, by the app's keys. */
export function rulesFor(policy: OrgPolicy | null): SettingsRules {
return policy ? settingsRulesFromPolicy(policy, { keyFor: settingKeyFor, themeIds: THEME_IDS }) : EMPTY_SETTINGS_RULES;
}
function sameClientAccount(account: AccountEntry): boolean {
return jmapClient.serverUrl === account.serverUrl && jmapClient.username === account.username;
}
/**
* Who the policy is for (webmail lib/admin/policy-subject.ts): the
* account's address, and the group accounts its session shows (shared,
* not personal, named by an address).
*/
export function policySubjectFor(account: AccountEntry): PolicySubject {
const email = (account.email || account.username).trim().toLowerCase();
const session = sameClientAccount(account) ? jmapClient.currentSession : null;
const groups = session
? Object.values(session.accounts)
.filter((info) => !info.isPersonal && info.name.includes('@'))
.map((info) => info.name.trim().toLowerCase())
.sort()
: [];
return { email, groups };
}
function isSecureUrl(url: string): boolean {
return /^https:\/\//i.test(url) || isLoopbackHttp(url);
}
async function readJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
return null;
}
}
/**
* GET <webmail>/api/admin/policy. With an access token for the account,
* the full policy for its domains; the token goes only over TLS, and names
* the account's server so the webmail checks it there and nowhere else.
* Without one (password accounts), the public part. Null when unreachable.
*/
export async function fetchOrgPolicy(
webmailUrl: string,
auth: { authorization: string; serverUrl: string } | null,
): Promise<{ policy: OrgPolicy; partial: boolean } | null> {
const base = webmailUrl.replace(/\/+$/, '');
const headers: Record<string, string> = { Accept: 'application/json' };
if (auth && /^Bearer\s/i.test(auth.authorization) && isSecureUrl(base)) {
headers.Authorization = auth.authorization;
headers['X-JMAP-Server-Url'] = auth.serverUrl;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), POLICY_FETCH_TIMEOUT_MS);
try {
const response = await fetchWithoutForeignRedirect(`${base}/api/admin/policy`, {
headers,
signal: controller.signal,
timeoutMs: POLICY_FETCH_TIMEOUT_MS,
});
if (!response.ok) return null;
const policy = sanitizePolicy(await readJson(response));
if (!policy) return null;
const partial = response.headers?.get?.(POLICY_SCOPE_HEADER) === 'public';
return { policy, partial };
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** The operator's links from <webmail>/api/config; null when unreachable. */
export async function fetchWebmailLinks(webmailUrl: string): Promise<WebmailLinks | null> {
const base = webmailUrl.replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), POLICY_FETCH_TIMEOUT_MS);
try {
const response = await fetchWithoutForeignRedirect(`${base}/api/config`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
timeoutMs: POLICY_FETCH_TIMEOUT_MS,
});
if (!response.ok) return null;
return webmailLinksFromConfig(await readJson(response));
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** The account's Authorization header when the live client serves it with a token. */
async function bearerFor(account: AccountEntry): Promise<{ authorization: string; serverUrl: string } | null> {
if (!sameClientAccount(account) || !jmapClient.usesBearerAuth) return null;
try {
await jmapClient.ensureFreshToken();
return { authorization: jmapClient.authHeader, serverUrl: account.serverUrl };
} catch {
return null;
}
}
const refreshes = new Map<string, Promise<void>>();
export const usePolicyStore = create<PolicyState>()(
persist(
(set, get) => ({
byAccount: {},
accountId: null,
effective: null,
rules: EMPTY_SETTINGS_RULES,
links: {},
activate: (accountId) => {
const account = accountId ? useAccountStore.getState().getAccountById(accountId) : undefined;
const cached = accountId ? get().byAccount[accountId] : undefined;
// Only a policy from the webmail the account signed in through.
const usable = account?.webmailUrl && cached && cached.webmailUrl === account.webmailUrl ? cached : null;
const effective = usable && account ? resolvePolicy(usable.policy, policySubjectFor(account)) : null;
const rules = rulesFor(effective);
set({ accountId: accountId ?? null, effective, rules, links: usable?.links ?? {} });
useSettingsStore.getState().applyPolicyRules(rules);
},
refresh: (accountId) => {
const running = refreshes.get(accountId);
if (running) return running;
const run = (async () => {
const account = useAccountStore.getState().getAccountById(accountId);
const webmailUrl = account?.webmailUrl;
if (!account || !webmailUrl) return;
const auth = await bearerFor(account);
const [fetched, links] = await Promise.all([fetchOrgPolicy(webmailUrl, auth), fetchWebmailLinks(webmailUrl)]);
if (!fetched) return; // offline or no policy route: keep what is cached
// Signed out of the account meanwhile.
if (!useAccountStore.getState().getAccountById(accountId)) return;
const previous = get().byAccount[accountId];
set({
byAccount: {
...get().byAccount,
[accountId]: {
policy: fetched.policy,
partial: fetched.partial,
fetchedAt: Date.now(),
webmailUrl,
links: links ?? (previous?.webmailUrl === webmailUrl ? previous.links : {}),
},
},
});
if (useAuthStore.getState().activeAccountId === accountId) get().activate(accountId);
})().finally(() => refreshes.delete(accountId));
refreshes.set(accountId, run);
return run;
},
prune: (accountIds) => {
const keep = new Set(accountIds);
const current = get().byAccount;
if (Object.keys(current).every((id) => keep.has(id))) return;
set({ byAccount: Object.fromEntries(Object.entries(current).filter(([id]) => keep.has(id))) });
},
}),
{
name: 'org-policy-cache',
storage: createPersistStorage(),
partialize: (state) => ({ byAccount: state.byAccount }) as unknown as PolicyState,
},
),
);
/** Whether a feature gate of the active account's policy is on (everything is without a webmail). */
export function usePolicyFeature(feature: FeatureGate): boolean {
return usePolicyStore((s) => isFeatureEnabled(s.effective, feature));
}
/** Whether the active account's policy pins a setting (by the app's key). */
export function useSettingLocked(key: string): boolean {
return usePolicyStore((s) => isLockedSetting(s.rules, key));
}
/** Whether the active account's policy hides a setting (by the app's key). */
export function useSettingHidden(key: string): boolean {
return usePolicyStore((s) => s.rules.hidden.includes(key));
}
let syncStarted = false;
/**
* Keep the policy following the active account: the cached one applies at
* once (also offline), and it is fetched again on start and on every
* account switch, once the account's session is up (its token is needed).
* Returns the unsubscribe.
*/
export function startPolicySync(): () => void {
if (syncStarted) return () => undefined;
syncStarted = true;
const t = () => useLocaleStore.getState().t;
setPolicyLockedWriteListener(() => {
useToastStore.getState().addToast({
type: 'info',
title: t()('settings.org_locked', 'Set by your organisation'),
});
});
let activeId: string | null = null;
let fetchedFor: string | null = null;
const follow = () => {
const auth = useAuthStore.getState();
const id = auth.activeAccountId ?? useAccountStore.getState().activeAccountId;
if (id !== activeId) {
activeId = id;
fetchedFor = null;
usePolicyStore.getState().activate(id);
}
if (id && auth.session && fetchedFor !== id) {
fetchedFor = id;
void usePolicyStore.getState().refresh(id);
}
};
const whenHydrated = (fn: () => void) => {
if (usePolicyStore.persist.hasHydrated()) fn();
else usePolicyStore.persist.onFinishHydration(fn);
};
whenHydrated(follow);
const offAuth = useAuthStore.subscribe((state, prev) => {
if (state.activeAccountId === prev.activeAccountId && state.session === prev.session) return;
whenHydrated(follow);
});
const offAccounts = useAccountStore.subscribe((state, prev) => {
if (state.accounts === prev.accounts) return;
usePolicyStore.getState().prune(state.accounts.map((a) => a.id));
// A sign-in through a webmail stores its address after the account
// became active: fetch now that it is known.
const id = activeId;
const now = id ? state.accounts.find((a) => a.id === id) : undefined;
const before = id ? prev.accounts.find((a) => a.id === id) : undefined;
if (id && now?.webmailUrl && now.webmailUrl !== before?.webmailUrl) {
fetchedFor = null;
whenHydrated(follow);
}
});
return () => {
offAuth();
offAccounts();
setPolicyLockedWriteListener(null);
syncStarted = false;
};
}
+160 -22
View File
@@ -5,6 +5,15 @@ import { getIdentities as fetchIdentities } from '../api/identity';
import { jmapClient } from '../api/jmap-client'; import { jmapClient } from '../api/jmap-client';
import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order'; import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order';
import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '../lib/notification-mutes'; import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '../lib/notification-mutes';
import {
EMPTY_SETTINGS_RULES,
isLockedSetting,
policyAllowsValue,
policyReleasePatch,
policySettingsPatch,
type PolicyPatchContext,
type SettingsRules,
} from '../lib/org-policy';
export type ExternalContentPolicy = 'allow' | 'block' | 'ask'; export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
export type ThemeMode = 'light' | 'dark' | 'system'; export type ThemeMode = 'light' | 'dark' | 'system';
@@ -336,6 +345,9 @@ export interface PersistedSettings {
// coloured bar at the start, instead of solid blocks (webmail // coloured bar at the start, instead of solid blocks (webmail
// calendarTintedEvents, same key). // calendarTintedEvents, same key).
calendarTintedEvents: boolean; calendarTintedEvents: boolean;
// Keys the user changed themselves (webmail #1248, same key). An
// organisation default only fills in settings not listed here.
explicitSettings: string[];
} }
const DEFAULT_PERSISTED: PersistedSettings = { const DEFAULT_PERSISTED: PersistedSettings = {
@@ -503,6 +515,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
quickReplyEnabled: true, quickReplyEnabled: true,
// calendar2: // calendar2:
calendarTintedEvents: false, calendarTintedEvents: false,
explicitSettings: [],
}; };
export interface SettingsState extends PersistedSettings { export interface SettingsState extends PersistedSettings {
@@ -567,6 +580,13 @@ export interface SettingsState extends PersistedSettings {
removeSidebarApp: (id: string) => void; removeSidebarApp: (id: string) => void;
reorderSidebarApps: (apps: SidebarApp[]) => void; reorderSidebarApps: (apps: SidebarApp[]) => void;
/**
* Apply what the active account's organisation policy does to the
* settings (lib/org-policy). Values the previous rules imposed and these
* don't go back to the default.
*/
applyPolicyRules: (rules: SettingsRules) => void;
// Restore every persisted key to its default (keeps identities/session state). // Restore every persisted key to its default (keeps identities/session state).
resetToDefaults: () => void; resetToDefaults: () => void;
// JSON blob in the webmail's export shape (lib/settings export) so a file // JSON blob in the webmail's export shape (lib/settings export) so a file
@@ -654,6 +674,7 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
offlineCacheMaxMB: intBetween(1, 100000), offlineCacheMaxMB: intBetween(1, 100000),
// platform: // platform:
toastDuration: oneOf([-1, 0, 5, 10, 20, 60]), toastDuration: oneOf([-1, 0, 5, 10, 20, 60]),
explicitSettings: stringArray,
sidebarApps: (v) => Array.isArray(v) && v.every((a) => sidebarApps: (v) => Array.isArray(v) && v.every((a) =>
a && typeof a === 'object' a && typeof a === 'object'
&& typeof (a as SidebarApp).id === 'string' && typeof (a as SidebarApp).id === 'string'
@@ -765,6 +786,76 @@ function identityScope(): string | null {
let identitiesInFlight: { scope: string | null; promise: Promise<void> } | null = null; let identitiesInFlight: { scope: string | null; promise: Promise<void> } | null = null;
// -- Organisation policy --------------------------------------------------
// The policy store hands over what the active account's webmail policy
// does to the settings (lib/org-policy settingsRulesFromPolicy); this store
// applies it: defaults for settings the user hasn't chosen, locks whatever
// they chose, and refuses a change to a locked setting.
let policyRules: SettingsRules = EMPTY_SETTINGS_RULES;
let lockedWriteListener: ((key: keyof PersistedSettings) => void) | null = null;
/** Settings a policy may set: plain values, and the theme. Lists and maps stay the user's. */
export function isPolicySettingKey(key: string): boolean {
if (key === 'explicitSettings' || !Object.prototype.hasOwnProperty.call(DEFAULT_PERSISTED, key)) return false;
if (key === 'activeThemeId') return true;
const type = typeof DEFAULT_PERSISTED[key as keyof PersistedSettings];
return type === 'boolean' || type === 'number' || type === 'string';
}
const POLICY_BUILT_IN: Record<string, unknown> = Object.fromEntries(
Object.entries(DEFAULT_PERSISTED).filter(([key]) => isPolicySettingKey(key)),
);
function isValidPolicyValue(key: string, value: unknown): boolean {
if (key === 'activeThemeId') return value === null || (typeof value === 'string' && value.length > 0);
const def = DEFAULT_PERSISTED[key as keyof PersistedSettings];
if (typeof value !== typeof def) return false;
if (typeof value === 'number' && !Number.isFinite(value)) return false;
const validator = VALIDATORS[key as keyof PersistedSettings];
return validator ? validator(value) : true;
}
function policyContext(explicit: readonly string[]): PolicyPatchContext {
return { builtIn: POLICY_BUILT_IN, explicit: new Set(explicit), isValid: isValidPolicyValue };
}
/**
* Settings from before explicitSettings existed: a value other than the
* build default can only have come from the user, so it counts as chosen
* (webmail legacyExplicitSettings).
*/
export function legacyExplicitSettings(source: Record<string, unknown>): string[] {
return Object.keys(source).filter(
(key) => isPolicySettingKey(key) && source[key] !== undefined && source[key] !== POLICY_BUILT_IN[key],
);
}
/** Whether the organisation policy pins `key`, so the user can't change it. */
export function isSettingLockedByPolicy(key: string): boolean {
return isLockedSetting(policyRules, key);
}
/** Called when a change to a locked setting was refused (the policy store shows a toast). */
export function setPolicyLockedWriteListener(listener: ((key: keyof PersistedSettings) => void) | null): void {
lockedWriteListener = listener;
}
/** Test hook. */
export function resetPolicyRulesForTests(): void {
policyRules = EMPTY_SETTINGS_RULES;
lockedWriteListener = null;
}
/** What the current policy changes in `state`; empty without one. */
function currentPolicyPatch(state: PersistedSettings): Partial<PersistedSettings> {
return policySettingsPatch(
state as unknown as Record<string, unknown>,
policyRules,
policyContext(state.explicitSettings),
) as Partial<PersistedSettings>;
}
export const useSettingsStore = create<SettingsState>((set, get) => ({ export const useSettingsStore = create<SettingsState>((set, get) => ({
...DEFAULT_PERSISTED, ...DEFAULT_PERSISTED,
identities: [], identities: [],
@@ -813,20 +904,55 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
const raw = await AsyncStorage.getItem(STORAGE_KEY); const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) { if (raw) {
const parsed = JSON.parse(raw) as Partial<PersistedSettings>; const parsed = JSON.parse(raw) as Partial<PersistedSettings>;
set({ ...mergeWithDefaults(parsed), hydrated: true }); const merged = mergeWithDefaults(parsed);
if (!Array.isArray(parsed.explicitSettings)) {
merged.explicitSettings = legacyExplicitSettings(merged as unknown as Record<string, unknown>);
}
// The policy may have arrived (from its cache) before the settings.
const patch = currentPolicyPatch(merged);
set({ ...merged, ...patch, hydrated: true });
if (Object.keys(patch).length > 0) persist(snapshot(get()));
return; return;
} }
} catch (err) { } catch (err) {
console.warn('[settings-store] hydrate failed', err); console.warn('[settings-store] hydrate failed', err);
} }
set({ hydrated: true }); const patch = currentPolicyPatch(snapshot(get()));
set({ ...patch, hydrated: true });
if (Object.keys(patch).length > 0) persist(snapshot(get()));
}, },
updateSetting: (key, value) => { updateSetting: (key, value) => {
set({ [key]: value } as Partial<SettingsState>); const current = get()[key];
if (value !== current && (isLockedSetting(policyRules, key) || !policyAllowsValue(key, value, policyRules))) {
lockedWriteListener?.(key);
return;
}
// The user chose this value, so an organisation default no longer replaces it.
const explicit = get().explicitSettings;
const mark = isPolicySettingKey(key) && !explicit.includes(key);
set({ [key]: value, ...(mark ? { explicitSettings: [...explicit, key] } : {}) } as Partial<SettingsState>);
persist(snapshot(get())); persist(snapshot(get()));
}, },
applyPolicyRules: (rules) => {
const previous = policyRules;
policyRules = rules;
const state = snapshot(get());
const released = policyReleasePatch(
state as unknown as Record<string, unknown>,
previous,
rules,
policyContext(state.explicitSettings),
) as Partial<PersistedSettings>;
const patch = { ...released, ...currentPolicyPatch({ ...state, ...released }) };
if (Object.keys(patch).length === 0) return;
set(patch as Partial<SettingsState>);
// Before the stored settings were read, writing would replace them;
// hydrate applies the rules again.
if (get().hydrated) persist(snapshot(get()));
},
forgetPreferredIdentities: async (jmapAccountIds) => { forgetPreferredIdentities: async (jmapAccountIds) => {
// Written before the settings were read, the defaults would replace them. // Written before the settings were read, the defaults would replace them.
await get().hydrate(); await get().hydrate();
@@ -837,22 +963,22 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
get().updateSetting('preferredIdentityIds', next); get().updateSetting('preferredIdentityIds', next);
}, },
setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); }, setExternalContentPolicy: (policy) => get().updateSetting('externalContentPolicy', policy),
setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); }, setSenderFavicons: (enabled) => get().updateSetting('senderFavicons', enabled),
setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); }, setGroupContactsByLetter: (enabled) => get().updateSetting('groupContactsByLetter', enabled),
setTheme: (theme) => { set({ theme }); persist(snapshot(get())); }, setTheme: (theme) => get().updateSetting('theme', theme),
setFontSize: (fontSize) => { set({ fontSize }); persist(snapshot(get())); }, setFontSize: (fontSize) => get().updateSetting('fontSize', fontSize),
setDensity: (density) => { set({ density }); persist(snapshot(get())); }, setDensity: (density) => get().updateSetting('density', density),
setShowToolbarLabels: (enabled) => { set({ showToolbarLabels: enabled }); persist(snapshot(get())); }, setShowToolbarLabels: (enabled) => get().updateSetting('showToolbarLabels', enabled),
setAnimationsEnabled: (enabled) => { set({ animationsEnabled: enabled }); persist(snapshot(get())); }, setAnimationsEnabled: (enabled) => get().updateSetting('animationsEnabled', enabled),
setEmailAlwaysLightMode: (enabled) => { set({ emailAlwaysLightMode: enabled }); persist(snapshot(get())); }, setEmailAlwaysLightMode: (enabled) => get().updateSetting('emailAlwaysLightMode', enabled),
setAutoSelectReplyIdentity: (enabled) => { set({ autoSelectReplyIdentity: enabled }); persist(snapshot(get())); }, setAutoSelectReplyIdentity: (enabled) => get().updateSetting('autoSelectReplyIdentity', enabled),
setAttachmentReminderEnabled: (enabled) => { set({ attachmentReminderEnabled: enabled }); persist(snapshot(get())); }, setAttachmentReminderEnabled: (enabled) => get().updateSetting('attachmentReminderEnabled', enabled),
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); }, setAttachmentReminderKeywords: (keywords) => get().updateSetting('attachmentReminderKeywords', keywords),
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); }, setSwipeLeftAction: (action) => get().updateSetting('swipeLeftAction', action),
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); }, setSwipeRightAction: (action) => get().updateSetting('swipeRightAction', action),
setSwipeMode: (mode) => { set({ swipeMode: mode }); persist(snapshot(get())); }, setSwipeMode: (mode) => get().updateSetting('swipeMode', mode),
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); }, setArchiveMode: (mode) => get().updateSetting('archiveMode', mode),
addTrustedSender: (email) => { addTrustedSender: (email) => {
const normalized = stripDisplayName(email); const normalized = stripDisplayName(email);
@@ -909,7 +1035,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
}, },
resetToDefaults: () => { resetToDefaults: () => {
set({ ...DEFAULT_PERSISTED }); set({ ...DEFAULT_PERSISTED, explicitSettings: [] });
// The defaults here are the organisation's, then the build's.
set(currentPolicyPatch(snapshot(get())) as Partial<SettingsState>);
persist(snapshot(get())); persist(snapshot(get()));
}, },
@@ -924,10 +1052,20 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
} }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
const incoming = fromExportShape(parsed as Record<string, unknown>); const incoming = fromExportShape(parsed as Record<string, unknown>);
// A file without explicitSettings (older clients): what it sets to
// something other than the default was chosen.
const chosen = Array.isArray(incoming.explicitSettings)
? incoming.explicitSettings.filter((key): key is string => typeof key === 'string')
: legacyExplicitSettings(incoming as Record<string, unknown>);
// Validate against the current state so keys absent from the file keep // Validate against the current state so keys absent from the file keep
// their value instead of snapping back to the default. // their value instead of snapping back to the default.
const merged = mergeWithDefaults({ ...snapshot(get()), ...incoming }); const merged = mergeWithDefaults({
set({ ...merged }); ...snapshot(get()),
...incoming,
explicitSettings: [...new Set([...get().explicitSettings, ...chosen])],
});
// Locks hold over an imported file too.
set({ ...merged, ...currentPolicyPatch(merged) });
persist(snapshot(get())); persist(snapshot(get()));
return true; return true;
}, },