From 9d9777738e6c22b1c1428475d298a9312f4044c3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:13:02 +0100 Subject: [PATCH] feat: apply the organisation policy of the webmail an account signed in through --- App.tsx | 25 +- .../__tests__/org-policy-wiring.test.ts | 44 ++ src/components/settings/AboutSettings.tsx | 21 + src/components/settings/LayoutSettings.tsx | 9 + .../settings/TroubleshootingSettings.tsx | 4 +- src/components/settings/settings-section.tsx | 5 +- src/lib/__tests__/org-policy.test.ts | 210 +++++++ src/lib/org-policy.ts | 515 ++++++++++++++++++ .../__tests__/auth-store-pairing.test.ts | 6 +- .../__tests__/auth-store-webmail-url.test.ts | 137 +++++ src/stores/__tests__/policy-store.test.ts | 207 +++++++ .../__tests__/settings-store-policy.test.ts | 87 +++ src/stores/account-store.ts | 6 + src/stores/auth-store.ts | 25 +- src/stores/policy-store.ts | 341 ++++++++++++ src/stores/settings-store.ts | 182 ++++++- 16 files changed, 1787 insertions(+), 37 deletions(-) create mode 100644 src/components/__tests__/org-policy-wiring.test.ts create mode 100644 src/lib/__tests__/org-policy.test.ts create mode 100644 src/lib/org-policy.ts create mode 100644 src/stores/__tests__/auth-store-webmail-url.test.ts create mode 100644 src/stores/__tests__/policy-store.test.ts create mode 100644 src/stores/__tests__/settings-store-policy.test.ts create mode 100644 src/stores/policy-store.ts diff --git a/App.tsx b/App.tsx index 2258bf4a..7172f76d 100644 --- a/App.tsx +++ b/App.tsx @@ -61,6 +61,7 @@ import { useEmailStore } from './src/stores/email-store'; import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache'; import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useSettingsStore } from './src/stores/settings-store'; +import { startPolicySync, usePolicyFeature } from './src/stores/policy-store'; import { useLocaleStore } from './src/stores/locale-store'; import { useLayout } from './src/lib/use-layout'; import { toast } from './src/stores/toast-store'; @@ -231,6 +232,12 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps null }; const t = useLocaleStore((state) => state.t); // The webmail's navigation names; a tab the server lacks says so. const tabLabels = { @@ -323,10 +330,11 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar), + ...(calendarAllowed ? null : hiddenTab), }} listeners={{ tabPress: (e) => { - if (!hasCalendar) e.preventDefault(); + if (!hasCalendar || !calendarAllowed) e.preventDefault(); }, }} /> @@ -339,10 +347,11 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarItemStyle: hasContacts ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts), + ...(contactsAllowed ? null : hiddenTab), }} listeners={{ tabPress: (e) => { - if (!hasContacts) e.preventDefault(); + if (!hasContacts || !contactsAllowed) e.preventDefault(); }, }} /> @@ -355,10 +364,11 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarItemStyle: hasFiles ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files), + ...(filesAllowed ? null : hiddenTab), }} listeners={{ 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 // foreground ask Android to sync; accounts are checked against Android's. 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 diff --git a/src/components/__tests__/org-policy-wiring.test.ts b/src/components/__tests__/org-policy-wiring.test.ts new file mode 100644 index 00000000..56cdabda --- /dev/null +++ b/src/components/__tests__/org-policy-wiring.test.ts @@ -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 && {t('settings.org_locked', 'Set by your organisation')}}"); + 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) => ('); + }); +}); diff --git a/src/components/settings/AboutSettings.tsx b/src/components/settings/AboutSettings.tsx index 96353523..025cb7ae 100644 --- a/src/components/settings/AboutSettings.tsx +++ b/src/components/settings/AboutSettings.tsx @@ -12,6 +12,8 @@ import { useAccountStore } from '../../stores/account-store'; import { useUpdatesStore } from '../../stores/updates-store'; import { supportsSideloadUpdates } from '../../lib/platform-capabilities'; 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_COLOR = require('../../../assets/logos/Bulwark Logo Color.png'); @@ -35,11 +37,21 @@ interface AboutSettingsProps { 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) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const scheme = useResolvedTheme(); const t = useLocaleStore((s) => s.t); + const operatorLinks = usePolicyStore((s) => s.links); const locale = useLocaleStore((s) => s.locale); 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')} onPress={() => onOpen?.('privacy')} /> + {/* The operator's accessibility and help links, from the account's webmail. */} + {helpLinks(operatorLinks).map((link) => ( + void Linking.openURL(link.href)} + link="external" + /> + ))} s.enableCrossUnreadView); const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView); 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 invalidateListOrder = useEmailStore((s) => s.invalidateListOrder); const order = React.useMemo(() => sanitizeSortLevels(messageListOrderRaw), [messageListOrderRaw]); @@ -185,18 +190,21 @@ export function LayoutSettings() { update('unifiedCrossAccount', v)} /> update('enableCrossUnreadView', v)} /> update('enableCrossStarredView', v)} /> @@ -204,6 +212,7 @@ export function LayoutSettings() { 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.')} noBorder + locked={crossAllLocked} > update('enableCrossAllView', v)} /> diff --git a/src/components/settings/TroubleshootingSettings.tsx b/src/components/settings/TroubleshootingSettings.tsx index c5ecb30d..014c4c82 100644 --- a/src/components/settings/TroubleshootingSettings.tsx +++ b/src/components/settings/TroubleshootingSettings.tsx @@ -6,6 +6,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { ALL_DEBUG_CATEGORIES, useSettingsStore, type DebugCategory } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; +import { useSettingLocked } from '../../stores/policy-store'; import { clearCachedData } from '../../lib/clear-cached-data'; /** Settings → Storage & backup → Troubleshooting (webmail: the Debug page). */ @@ -14,6 +15,7 @@ export function TroubleshootingSettings() { const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const debugMode = useSettingsStore((s) => s.debugMode); + const debugModeLocked = useSettingLocked('debugMode'); const debugCategories = useSettingsStore((s) => s.debugCategories); const updateSetting = useSettingsStore((s) => s.updateSetting); const [refreshing, setRefreshing] = useState(false); @@ -56,7 +58,7 @@ export function TroubleshootingSettings() { title={t('settings.advanced.debug_mode.label', 'Debug Mode')} description={t('settings.advanced.debug_mode.description', 'Enable detailed logging for troubleshooting')} > - + updateSetting('debugMode', v)} /> {debugMode && ( diff --git a/src/components/settings/settings-section.tsx b/src/components/settings/settings-section.tsx index cf7aa49e..e22a4353 100644 --- a/src/components/settings/settings-section.tsx +++ b/src/components/settings/settings-section.tsx @@ -98,6 +98,7 @@ export function SettingItem({ }: SettingItemProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const highlight = useSearchHighlight(label); // The control sits on the right. When that leaves the label column narrower // than STACK_BELOW (a long translation, two buttons), the control moves @@ -125,10 +126,12 @@ export function SettingItem({ {locked && } {description && {description}} + {locked && {t('settings.org_locked', 'Set by your organisation')}} {children && ( - {children} + {/* A locked setting shows its value but can't be changed. */} + {children} )} {onPress && (link === 'external' diff --git a/src/lib/__tests__/org-policy.test.ts b/src/lib/__tests__/org-policy.test.ts new file mode 100644 index 00000000..c5ff22a4 --- /dev/null +++ b/src/lib/__tests__/org-policy.test.ts @@ -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)[key], +}); + +const policy = (raw: Record) => 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([]); + }); +}); diff --git a/src/lib/org-policy.ts b/src/lib/org-policy.ts new file mode 100644 index 00000000..0e25e29f --- /dev/null +++ b/src/lib/org-policy.ts @@ -0,0 +1,515 @@ +/** + * The organisation policy of a Bulwark webmail (GET /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; + restrictions?: Record; + defaults?: Record; + organisation?: Record; + }; +} + +/** The parts of the webmail's SettingsPolicy the app uses. */ +export interface OrgPolicy { + restrictions: Record; + features: Record; + defaults: Record; + themePolicy: ThemePolicy; + forceEnabledThemes: string[]; + /** Organisation settings (signatures, disclaimer...), kept as sent. */ + organisation?: Record; + /** 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 { + 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 { + const out: Record = {}; + 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 { + const out: Record = {}; + 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 { + const out: Record = {}; + 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(); + 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 = { ...(organisation ?? {}) }; + for (const [section, values] of Object.entries(scope.policy.organisation)) { + merged[section] = isRecord(values) && isRecord(merged[section]) + ? { ...(merged[section] as Record), ...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; + locks: Record; + restrictions: Record; + blocked: Record; + 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 = 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; + /** Settings the user chose themselves. */ + explicit: ReadonlySet; + /** 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[] { + 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, + rules: SettingsRules, + ctx: PolicyPatchContext, +): Record { + const patch: Record = {}; + 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 { + const out = new Map(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, + previous: SettingsRules, + next: SettingsRules, + ctx: Pick, +): Record { + const before = imposed(previous); + const after = imposed(next); + const patch: Record = {}; + 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; +} diff --git a/src/stores/__tests__/auth-store-pairing.test.ts b/src/stores/__tests__/auth-store-pairing.test.ts index c0b6f7cb..32105f31 100644 --- a/src/stores/__tests__/auth-store-pairing.test.ts +++ b/src/stores/__tests__/auth-store-pairing.test.ts @@ -145,11 +145,11 @@ describe('loginViaPairing with an app password', () => { useAuthStore.setState({ login }); try { 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); 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 { useAuthStore.setState({ login: original }); } @@ -177,7 +177,7 @@ describe('loginViaPairing with an app password', () => { await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, newCode())).rejects.toBe(totp); 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); }); diff --git a/src/stores/__tests__/auth-store-webmail-url.test.ts b/src/stores/__tests__/auth-store-webmail-url.test.ts new file mode 100644 index 00000000..50ac4d05 --- /dev/null +++ b/src/stores/__tests__/auth-store-webmail-url.test.ts @@ -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]>) => ({ + 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]>) => 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()), + redeemPairingCode: vi.fn(), + runWebmailHandoff: vi.fn(), +})); + +vi.mock('../../lib/oauth-native', async (importOriginal) => ({ + ...(await importOriginal()), + 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; +const handoff = runWebmailHandoff as ReturnType; + +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).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'); + }); +}); diff --git a/src/stores/__tests__/policy-store.test.ts b/src/stores/__tests__/policy-store.test.ts new file mode 100644 index 00000000..85bb7970 --- /dev/null +++ b/src/stores/__tests__/policy-store.test.ts @@ -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; +let configs: Record; +let publicScope: boolean; + +function response(url: string, data: unknown, headers: Record = {}): 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(); + }); +}); diff --git a/src/stores/__tests__/settings-store-policy.test.ts b/src/stores/__tests__/settings-store-policy.test.ts new file mode 100644 index 00000000..750c4eef --- /dev/null +++ b/src/stores/__tests__/settings-store-policy.test.ts @@ -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 => ({ 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'); + }); +}); diff --git a/src/stores/account-store.ts b/src/stores/account-store.ts index cf871cd3..4877605d 100644 --- a/src/stores/account-store.ts +++ b/src/stores/account-store.ts @@ -23,6 +23,10 @@ export interface AccountEntry { // 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. 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 { @@ -67,6 +71,8 @@ export const useAccountStore = create()( serverUrl: entry.serverUrl || a.serverUrl, displayName: entry.displayName || a.displayName, 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, isConnected: entry.isConnected, hasError: entry.hasError, diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 8b514472..38d50fad 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -108,7 +108,7 @@ export interface AuthState { * login screen re-runs it with a code via `login(..., { totp })`. Cleared * 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. */ reauth: ReauthTarget | null; @@ -336,6 +336,11 @@ async function switchesSettled(): Promise { export interface SignInOptions { addAccount?: boolean; 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 { @@ -421,6 +426,7 @@ async function signIn( lastLoginAt: Date.now(), isConnected: true, hasError: false, + ...(opts?.webmailUrl ? { webmailUrl: opts.webmailUrl.replace(/\/+$/, '') } : {}), }); } catch (err) { // A cancelled connect may already hold the client: take it back. @@ -642,6 +648,13 @@ export const useAuthStore = create((set, get) => ({ // An address on an internationalized domain signs in with the domain's // ASCII form, the one Stalwart stores (#1100); the local part stays. 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 { // A new account with no room left fails here, before connect swaps // the live client over or stores credentials for it. @@ -662,7 +675,7 @@ export const useAuthStore = create((set, get) => ({ if (err instanceof Error && err.name === 'TotpRequiredError') { // Keep what the user (or the webmail hand-off) supplied so the code // 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' ? 'Two-factor code required' @@ -734,7 +747,7 @@ export const useAuthStore = create((set, get) => ({ // Hand the credentials to the existing password login path so account // registration + feature-store wiring all behave identically to a // 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; } @@ -742,7 +755,7 @@ export const useAuthStore = create((set, get) => ({ // token bundle. Bootstrap the JMAP session with Bearer auth and let // ensure/forceRefreshToken keep it alive going forward. try { - await completeOAuthHandoff(set, get, result, opts); + await completeOAuthHandoff(set, get, result, { ...opts, webmailUrl }); } catch (err) { set({ isLoading: false, error: oauthSignInMessage(err) }); throw err; @@ -838,7 +851,7 @@ export const useAuthStore = create((set, get) => ({ // password): the normal password sign-in, which also keeps the live // account when adding another one fails. 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) { throw connectFailed(err); } @@ -846,7 +859,7 @@ export const useAuthStore = create((set, get) => ({ } try { - await completeOAuthHandoff(set, get, result, opts); + await completeOAuthHandoff(set, get, result, { ...opts, webmailUrl }); } catch (err) { throw connectFailed(err); } diff --git a/src/stores/policy-store.ts b/src/stores/policy-store.ts new file mode 100644 index 00000000..664cfa3e --- /dev/null +++ b/src/stores/policy-store.ts @@ -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; + /** 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; + /** Drop the cached policies of accounts no longer in the registry. */ + prune: (accountIds: readonly string[]) => void; +} + +const reverseKeyMap = new Map( + 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 { + try { + return await response.json(); + } catch { + return null; + } +} + +/** + * GET /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 = { 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 /api/config; null when unreachable. */ +export async function fetchWebmailLinks(webmailUrl: string): Promise { + 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>(); + +export const usePolicyStore = create()( + 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; + }; +} diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 4403619e..b0b2fc08 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -5,6 +5,15 @@ import { getIdentities as fetchIdentities } from '../api/identity'; import { jmapClient } from '../api/jmap-client'; import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order'; 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 ThemeMode = 'light' | 'dark' | 'system'; @@ -336,6 +345,9 @@ export interface PersistedSettings { // coloured bar at the start, instead of solid blocks (webmail // calendarTintedEvents, same key). 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 = { @@ -503,6 +515,7 @@ const DEFAULT_PERSISTED: PersistedSettings = { quickReplyEnabled: true, // calendar2: calendarTintedEvents: false, + explicitSettings: [], }; export interface SettingsState extends PersistedSettings { @@ -567,6 +580,13 @@ export interface SettingsState extends PersistedSettings { removeSidebarApp: (id: string) => 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). resetToDefaults: () => void; // JSON blob in the webmail's export shape (lib/settings export) so a file @@ -654,6 +674,7 @@ const VALIDATORS: Partial boolea offlineCacheMaxMB: intBetween(1, 100000), // platform: toastDuration: oneOf([-1, 0, 5, 10, 20, 60]), + explicitSettings: stringArray, sidebarApps: (v) => Array.isArray(v) && v.every((a) => a && typeof a === 'object' && typeof (a as SidebarApp).id === 'string' @@ -765,6 +786,76 @@ function identityScope(): string | null { let identitiesInFlight: { scope: string | null; promise: Promise } | 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 = 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[] { + 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 { + return policySettingsPatch( + state as unknown as Record, + policyRules, + policyContext(state.explicitSettings), + ) as Partial; +} + export const useSettingsStore = create((set, get) => ({ ...DEFAULT_PERSISTED, identities: [], @@ -813,20 +904,55 @@ export const useSettingsStore = create((set, get) => ({ const raw = await AsyncStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as Partial; - set({ ...mergeWithDefaults(parsed), hydrated: true }); + const merged = mergeWithDefaults(parsed); + if (!Array.isArray(parsed.explicitSettings)) { + merged.explicitSettings = legacyExplicitSettings(merged as unknown as Record); + } + // 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; } } catch (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) => { - set({ [key]: value } as Partial); + 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); persist(snapshot(get())); }, + applyPolicyRules: (rules) => { + const previous = policyRules; + policyRules = rules; + const state = snapshot(get()); + const released = policyReleasePatch( + state as unknown as Record, + previous, + rules, + policyContext(state.explicitSettings), + ) as Partial; + const patch = { ...released, ...currentPolicyPatch({ ...state, ...released }) }; + if (Object.keys(patch).length === 0) return; + set(patch as Partial); + // Before the stored settings were read, writing would replace them; + // hydrate applies the rules again. + if (get().hydrated) persist(snapshot(get())); + }, + forgetPreferredIdentities: async (jmapAccountIds) => { // Written before the settings were read, the defaults would replace them. await get().hydrate(); @@ -837,22 +963,22 @@ export const useSettingsStore = create((set, get) => ({ get().updateSetting('preferredIdentityIds', next); }, - setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); }, - setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); }, - setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); }, - setTheme: (theme) => { set({ theme }); persist(snapshot(get())); }, - setFontSize: (fontSize) => { set({ fontSize }); persist(snapshot(get())); }, - setDensity: (density) => { set({ density }); persist(snapshot(get())); }, - setShowToolbarLabels: (enabled) => { set({ showToolbarLabels: enabled }); persist(snapshot(get())); }, - setAnimationsEnabled: (enabled) => { set({ animationsEnabled: enabled }); persist(snapshot(get())); }, - setEmailAlwaysLightMode: (enabled) => { set({ emailAlwaysLightMode: enabled }); persist(snapshot(get())); }, - setAutoSelectReplyIdentity: (enabled) => { set({ autoSelectReplyIdentity: enabled }); persist(snapshot(get())); }, - setAttachmentReminderEnabled: (enabled) => { set({ attachmentReminderEnabled: enabled }); persist(snapshot(get())); }, - setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); }, - setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); }, - setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); }, - setSwipeMode: (mode) => { set({ swipeMode: mode }); persist(snapshot(get())); }, - setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); }, + setExternalContentPolicy: (policy) => get().updateSetting('externalContentPolicy', policy), + setSenderFavicons: (enabled) => get().updateSetting('senderFavicons', enabled), + setGroupContactsByLetter: (enabled) => get().updateSetting('groupContactsByLetter', enabled), + setTheme: (theme) => get().updateSetting('theme', theme), + setFontSize: (fontSize) => get().updateSetting('fontSize', fontSize), + setDensity: (density) => get().updateSetting('density', density), + setShowToolbarLabels: (enabled) => get().updateSetting('showToolbarLabels', enabled), + setAnimationsEnabled: (enabled) => get().updateSetting('animationsEnabled', enabled), + setEmailAlwaysLightMode: (enabled) => get().updateSetting('emailAlwaysLightMode', enabled), + setAutoSelectReplyIdentity: (enabled) => get().updateSetting('autoSelectReplyIdentity', enabled), + setAttachmentReminderEnabled: (enabled) => get().updateSetting('attachmentReminderEnabled', enabled), + setAttachmentReminderKeywords: (keywords) => get().updateSetting('attachmentReminderKeywords', keywords), + setSwipeLeftAction: (action) => get().updateSetting('swipeLeftAction', action), + setSwipeRightAction: (action) => get().updateSetting('swipeRightAction', action), + setSwipeMode: (mode) => get().updateSetting('swipeMode', mode), + setArchiveMode: (mode) => get().updateSetting('archiveMode', mode), addTrustedSender: (email) => { const normalized = stripDisplayName(email); @@ -909,7 +1035,9 @@ export const useSettingsStore = create((set, get) => ({ }, 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); persist(snapshot(get())); }, @@ -924,10 +1052,20 @@ export const useSettingsStore = create((set, get) => ({ } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; const incoming = fromExportShape(parsed as Record); + // 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); // Validate against the current state so keys absent from the file keep // their value instead of snapping back to the default. - const merged = mergeWithDefaults({ ...snapshot(get()), ...incoming }); - set({ ...merged }); + const merged = mergeWithDefaults({ + ...snapshot(get()), + ...incoming, + explicitSettings: [...new Set([...get().explicitSettings, ...chosen])], + }); + // Locks hold over an imported file too. + set({ ...merged, ...currentPolicyPatch(merged) }); persist(snapshot(get())); return true; },