diff --git a/app/(main)/admin/__tests__/policy-locks-defaults.test.tsx b/app/(main)/admin/__tests__/policy-locks-defaults.test.tsx new file mode 100644 index 000000000..a3e691afd --- /dev/null +++ b/app/(main)/admin/__tests__/policy-locks-defaults.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; +import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft'; + +// AL-12: a lock set on the User policy page carries the value users get, +// and the page presets any setting, with an Accessibility category that +// offers presets for everything but locks only for the few options that +// can't leave someone unable to use the app. + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + puts.push(JSON.parse(String(init.body))); + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored), { headers: { 'content-type': 'application/json' } }); + }), + withBasePath: (p: string) => p, +})); + +import { PolicyPage } from '../_pages/policy'; + +const BASE = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + pushRelays: [], pushRelayUrl: '', pushRelayUrlLocked: false, defaultSidebarApps: [], +}; + +/** The settings row (label plus controls) for `label` inside a section. */ +function row(section: HTMLElement, label: string): HTMLElement { + const name = within(section).getByText(label, { selector: 'span, label, p, div' }); + let el: HTMLElement | null = name; + while (el && !el.hasAttribute('data-search-label')) el = el.parentElement; + if (!el) throw new Error(`no row for ${label}`); + return el; +} + +const section = (id: string) => document.querySelector(`[data-settings-section="${id}"]`)!; + +async function save() { + fireEvent.click(screen.getByRole('button', { name: 'Save policy' })); + await waitFor(() => expect(puts.length).toBe(1)); + return puts[0] as { restrictions: Record; defaults: Record; scopes?: Array<{ policy: Record }> }; +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(BASE)); + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); +afterEach(() => { + useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null }); +}); + +describe('User policy page: locks', () => { + it('locks single-key shortcuts to the value picked', async () => { + render(); + await screen.findByText('Locked settings'); + const shortcuts = row(section('restrictions'), 'Single-key shortcuts'); + fireEvent.click(within(shortcuts).getByRole('checkbox', { name: /Lock/ })); + const value = within(shortcuts).getByRole('switch', { name: 'Value users get for Single-key shortcuts' }); + expect(value.getAttribute('aria-checked')).toBe('true'); + fireEvent.click(value); + + const body = await save(); + expect(body.restrictions.singleKeyShortcuts).toEqual({ locked: true, value: false }); + }); + + it('sets a minimum notification duration instead of a lock', async () => { + render(); + await screen.findByText('Locked settings'); + const toast = row(section('restrictions'), 'Notification duration'); + expect(within(toast).queryByRole('checkbox', { name: /Lock/ })).toBeNull(); + fireEvent.change(within(toast).getByRole('combobox', { name: 'Minimum for Notification duration' }), { target: { value: '20' } }); + + const body = await save(); + expect(body.restrictions.toastDuration).toEqual({ min: 20 }); + }); + + it('offers no lock for text size, contrast or motion', async () => { + render(); + await screen.findByText('Locked settings'); + const locks = section('restrictions'); + for (const label of ['Text size', 'Contrast', 'Reduce motion', 'Font', 'Line spacing']) { + expect(within(locks).queryByText(label)).toBeNull(); + } + expect(within(locks).getByText('Confirm before sending')).toBeTruthy(); + }); + + it('offers to give an old lock without a value one', async () => { + stored = { ...BASE, restrictions: { showPreview: { locked: true } }, defaults: { showPreview: false } }; + render(); + await screen.findByText('Locked settings'); + const preview = row(section('restrictions'), 'Show Preview'); + expect(within(preview).getByText('No value: each user keeps the value they had.')).toBeTruthy(); + fireEvent.click(within(preview).getByRole('button', { name: 'Lock to Off' })); + + const body = await save(); + expect(body.restrictions.showPreview).toEqual({ locked: true, value: false }); + }); +}); + +describe('User policy page: defaults', () => { + it('presets accessibility options, including ones that cannot be locked', async () => { + render(); + await screen.findByText('Defaults'); + const accessibility = screen.getByTestId('defaults-Accessibility'); + fireEvent.change(within(row(accessibility, 'Text size')).getByRole('combobox'), { target: { value: 'xx-large' } }); + fireEvent.change(within(row(accessibility, 'Contrast')).getByRole('combobox'), { target: { value: 'more' } }); + fireEvent.change(within(row(accessibility, 'Notification duration')).getByRole('combobox'), { target: { value: '20' } }); + fireEvent.click(within(row(accessibility, 'Underline links')).getByRole('switch')); + + const body = await save(); + expect(body.defaults).toEqual({ fontSize: 'xx-large', contrast: 'more', toastDuration: 20, underlineLinks: true }); + }); + + it('removes a default and keeps unknown ones from policy.json', async () => { + stored = { ...BASE, defaults: { reduceMotion: 'on', dateFormat: 'iso' } }; + render(); + await screen.findByText('Defaults'); + const defaults = section('defaults'); + fireEvent.click(within(row(defaults, 'Reduce motion')).getByRole('button', { name: 'Remove the default' })); + expect(within(row(defaults, 'dateFormat')).getByRole('textbox')).toBeTruthy(); + + const body = await save(); + expect(body.defaults).toEqual({ dateFormat: 'iso' }); + }); + + it('writes into the domain or group scope picked', async () => { + stored = { + ...BASE, + defaults: { fontSize: 'large' }, + scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {} }], + }; + render(); + await screen.findByText('Defaults'); + const defaults = section('defaults'); + fireEvent.click(within(defaults).getByRole('button', { name: 'Team' })); + const size = row(defaults, 'Text size'); + expect(within(size).getByText('From Everyone')).toBeTruthy(); + fireEvent.change(within(size).getByRole('combobox'), { target: { value: 'huge' } }); + + const body = await save(); + expect(body.defaults).toEqual({ fontSize: 'large' }); + expect(body.scopes?.[0].policy.defaults).toEqual({ fontSize: 'huge' }); + }); +}); diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx index 74fce58a7..62fdac9d7 100644 --- a/app/(main)/admin/_pages/policy.tsx +++ b/app/(main)/admin/_pages/policy.tsx @@ -1,13 +1,12 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons'; +import { Loader2, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons'; import { iconForName } from '@/components/icons'; import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types'; import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; import { adminPageDef } from '@/lib/admin/admin-nav'; -import { restrictionKeysForPage } from '@/lib/admin/restriction-keys'; import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps'; import { generateUUID, cn } from '@/lib/utils'; @@ -25,6 +24,7 @@ import { AdminSaveBar } from '@/components/admin/save-bar'; import { Badge } from '@/components/admin/badge'; import { useAdminFeedback } from '@/components/admin/feedback'; import { inputClass } from '@/components/admin/config-items'; +import { LockedSettingsSection, PolicyDefaultsSection } from '@/components/admin/policy/user-settings-policy'; // `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized // forward on policy load), so it is hidden from the admin UI. @@ -56,13 +56,10 @@ const FEATURE_GATE_LABELS: Partial { - const existing = prev.restrictions[settingKey] || {}; - const newRestrictions = { ...prev.restrictions }; - if (existing.locked) { - delete newRestrictions[settingKey]; - } else { - newRestrictions[settingKey] = { ...existing, locked: true }; - } - return { ...prev, restrictions: newRestrictions }; - }); - setDirty(true); - setMessage(null); - } - - function toggleHidden(settingKey: string) { - setPolicy(prev => { - const existing = prev.restrictions[settingKey] || {}; - const newRestrictions = { ...prev.restrictions }; - newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden }; - if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) { - delete newRestrictions[settingKey]; - } - return { ...prev, restrictions: newRestrictions }; - }); + /** Locks and defaults, for Everyone or a scope (components/admin/policy). */ + function changePolicy(change: (prev: SettingsPolicy) => SettingsPolicy) { + setPolicy(change); setDirty(true); setMessage(null); } @@ -264,7 +239,6 @@ export function PolicyPage() { useAdminUnsavedChanges('policy', dirty); - const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))]; const defaultRelayUrl = resolveDefaultRelayUrl(policy); const defaultSidebarApps = policy.defaultSidebarApps ?? []; @@ -568,36 +542,9 @@ export function PolicyPage() { })} - - {categories.map(category => ( -
-

{category}

- {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { - const restriction = policy.restrictions[setting.key] || {}; - return ( - -
- - -
-
- ); - })} -
- ))} -
+ + + )} diff --git a/components/admin/policy/user-settings-policy.tsx b/components/admin/policy/user-settings-policy.tsx new file mode 100644 index 000000000..9576d067a --- /dev/null +++ b/components/admin/policy/user-settings-policy.tsx @@ -0,0 +1,419 @@ +'use client'; + +import { useState } from 'react'; +import { Lock, Plus, RotateCcw } from '@/components/icons'; +import { SettingsSection, SettingItem, ToggleSwitch, Select } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/admin/badge'; +import { AdminLink } from '@/components/admin/admin-link'; +import { inputClass, RevertButton } from '@/components/admin/config-items'; +import { chipClass, scopeMatchSummary } from '@/components/admin/org/org-page'; +import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft'; +import type { SettingRestriction, SettingsPolicy } from '@/lib/admin/types'; +import { restrictionKeysForPage, type RestrictionKeyDef } from '@/lib/admin/restriction-keys'; +import { + PRESETTABLE_SETTINGS, + defaultAt, + describeSettingValue, + effectiveDefault, + isLockableSetting, + otherDefaultKeys, + parseFreeSettingValue, + presettableSetting, + restrictionAt, + withDefaultAt, + withRestrictionAt, + type PolicySettingValue, + type PresettableSetting, +} from '@/lib/admin/policy-settings'; +import { toastDurationAtLeast } from '@/lib/accessibility-settings'; +import { cn } from '@/lib/utils'; + +type PolicyChange = (change: (policy: SettingsPolicy) => SettingsPolicy) => void; + +interface SectionProps { + policy: SettingsPolicy; + onChange: PolicyChange; +} + +const subLabelClass = 'mt-2 text-xs font-semibold text-muted-foreground'; +const groupLabelClass = 'mt-4 text-sm font-medium text-foreground'; +const noteClass = 'text-xs text-muted-foreground'; + +// The lock list leaves out what the store would not lock anyway. +const LOCKABLE = restrictionKeysForPage('policy').filter((def) => isLockableSetting(def.key)); + +/** Minimums the toast duration lock offers; 5 seconds is already the default. */ +const TOAST_MINIMUMS = [10, 20, 60]; + +const UNLOCKABLE_NOTE = + 'Text size, contrast, motion, fonts and the reading aids can be given a default below but not locked: a lock could leave someone unable to read or use Bulwark.'; + +/** The scope this page edits: the one picked on any Organisation page, if it still exists. */ +function useScopeId(policy: SettingsPolicy): [string, (id: string) => void] { + const scopeId = useOrgScopeStore((s) => s.scopeId); + const setScopeId = useOrgScopeStore((s) => s.setScopeId); + const exists = scopeId === EVERYONE || (policy.scopes ?? []).some((s) => s.id === scopeId); + return [exists ? scopeId : EVERYONE, setScopeId]; +} + +/** Everyone or one domain or group scope, for the locks and presets below it. */ +function ScopePicker({ policy, scopeId, onPick }: { policy: SettingsPolicy; scopeId: string; onPick: (id: string) => void }) { + const scopes = policy.scopes ?? []; + if (scopes.length === 0) return null; + const scope = scopes.find((s) => s.id === scopeId); + return ( +
+
+ Applies to + + {scopes.map((s) => ( + + ))} + + Manage domains and groups + +
+ {scope && ( +

+ Changes here apply to {scopeMatchSummary(scope)} only. What this scope doesn't change comes from Everyone. +

+ )} +
+ ); +} + +/** "From Everyone: On" under a row, with a way back to Everyone's value. */ +function InheritLine({ scoped, own, everyone, onReset }: { scoped: boolean; own: boolean; everyone: string; onReset: () => void }) { + if (!scoped) return null; + return ( +

+ {own ? 'Everyone' : 'From Everyone'}: {everyone} + {own && ( + + )} +

+ ); +} + +/** + * The input for one setting's value: a switch, a list of its values, a + * number or a text field. `ariaLabel` names it when the row holds more + * than this one field. + */ +export function SettingValueInput({ setting, value, onChange, ariaLabel }: { + setting: PresettableSetting; + value: unknown; + onChange: (value: PolicySettingValue) => void; + ariaLabel?: string; +}) { + if (setting.type === 'boolean') { + return ; + } + if (setting.type === 'enum' && setting.options) { + const options = setting.options.map((o) => ({ value: String(o.value), label: o.label })); + // A value from a hand-written policy.json that isn't one of the choices + // stays visible instead of silently showing another one. + if (value !== undefined && !setting.options.some((o) => o.value === value)) { + options.unshift({ value: String(value), label: `${String(value)} (not valid)` }); + } + return ( + { if (e.target.value !== '' && Number.isFinite(Number(e.target.value))) onChange(Number(e.target.value)); }} + className={cn(inputClass, 'w-28')} + /> + ); + } + return ( + onChange(e.target.value)} + className={cn(inputClass, 'w-full min-w-0 sm:w-64')} + /> + ); +} + +function describeRestriction(key: string, r: SettingRestriction | undefined): string { + if (!r) return 'users choose'; + const parts: string[] = []; + if (r.locked) parts.push(r.value === undefined ? 'locked, no value' : `locked to ${describeSettingValue(key, r.value)}`); + if (typeof r.min === 'number') parts.push(`at least ${r.min} seconds`); + if (r.hidden) parts.push('hidden'); + return parts.length ? parts.join(', ') : 'users choose'; +} + +function LockRow({ def, policy, scopeId, onChange }: { def: RestrictionKeyDef; policy: SettingsPolicy; scopeId: string; onChange: PolicyChange }) { + const { value, own, everyone } = restrictionAt(policy, scopeId, def.key); + const current: SettingRestriction = value ?? {}; + const modes = def.modes ?? ['locked', 'hidden']; + const setting = presettableSetting(def.key); + const set = (patch: SettingRestriction) => + onChange((p) => withRestrictionAt(p, scopeId, def.key, { ...current, ...patch })); + const lockTo = () => effectiveDefault(policy, scopeId, def.key) as PolicySettingValue | undefined; + + return ( + +
+
+ {modes.includes('locked') && ( + + )} + {modes.includes('locked') && current.locked && setting && current.value !== undefined && ( + set({ value: v })} + /> + )} + {modes.includes('min') && ( + set({ hidden: e.target.checked })} /> + Hide + + )} +
+ {current.locked && current.value === undefined && ( + // Locks from before the value picker, or from a hand-written + // policy.json, freeze whatever each user had. +

+ No value: each user keeps the value they had. + {lockTo() !== undefined && ( + + )} +

+ )} + onChange((p) => withRestrictionAt(p, scopeId, def.key, undefined))} + /> +
+
+ ); +} + +/** + * Lock a setting to a value, set a minimum, or hide it from users' settings. + * Edits Everyone or the domain or group scope picked at the top. + */ +export function LockedSettingsSection({ policy, onChange }: SectionProps) { + const [scopeId, setScopeId] = useScopeId(policy); + const categories = [...new Set(LOCKABLE.map((s) => s.category))]; + return ( + + + {categories.map((category) => ( +
+

{category}

+ {category === 'Accessibility' &&

{UNLOCKABLE_NOTE}

} + {LOCKABLE.filter((s) => s.category === category).map((def) => ( + + ))} +
+ ))} +
+ ); +} + +function DefaultRow({ setting, policy, scopeId, onChange }: { setting: PresettableSetting; policy: SettingsPolicy; scopeId: string; onChange: PolicyChange }) { + const preset = defaultAt(policy, scopeId, setting.key); + const shown = preset.value !== undefined ? preset.value : setting.defaultValue; + const restriction = restrictionAt(policy, scopeId, setting.key).value; + const scoped = scopeId !== EVERYONE; + const set = (value: PolicySettingValue | undefined) => onChange((p) => withDefaultAt(p, scopeId, setting.key, value)); + + let conflict: string | null = null; + if (restriction?.locked && restriction.value !== undefined && isLockableSetting(setting.key)) { + conflict = `Locked to ${describeSettingValue(setting.key, restriction.value)} above; the lock wins.`; + } else if (setting.key === 'toastDuration' && typeof restriction?.min === 'number' && typeof shown === 'number' + && !toastDurationAtLeast(shown, restriction.min)) { + conflict = `Below the minimum: users get ${restriction.min} seconds.`; + } + + return ( + +
+
+ {preset.own && set} + + {preset.own && set(undefined)} title={scoped ? "Use Everyone's value" : 'Remove the default'} />} +
+ {scoped && !preset.own && preset.everyone !== undefined && ( +

From Everyone

+ )} + {conflict &&

{conflict}

} +
+
+ ); +} + +const KEY_RE = /^[A-Za-z][A-Za-z0-9]*$/; + +/** Presets for settings the editor has no row for, from policy.json or typed in. */ +function OtherDefaults({ policy, scopeId, onChange }: { policy: SettingsPolicy; scopeId: string; onChange: PolicyChange }) { + const [key, setKey] = useState(''); + const [text, setText] = useState(''); + const [error, setError] = useState(null); + const keys = otherDefaultKeys(policy, scopeId); + const defaults = (scopeId === EVERYONE + ? policy.defaults + : (policy.scopes ?? []).find((s) => s.id === scopeId)?.policy.defaults) ?? {}; + + const add = () => { + const name = key.trim(); + if (!KEY_RE.test(name)) { setError('Enter the setting\'s key as it appears in a settings export, such as "dateFormat".'); return; } + if (presettableSetting(name)) { setError('This setting has its own row above.'); return; } + setError(null); + onChange((p) => withDefaultAt(p, scopeId, name, parseFreeSettingValue(text))); + setKey(''); + setText(''); + }; + + return ( +
+

Other settings

+

+ Any other setting from a settings export can get a default by its key. Use true or false for switches, a number, or text. + A value of the wrong type is ignored by users' browsers. +

+ {keys.map((name) => { + const value = defaults[name]; + const type = typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'text'; + const setting: PresettableSetting = { key: name, label: name, category: 'Other', type, defaultValue: '' }; + return ( + +
+ onChange((p) => withDefaultAt(p, scopeId, name, v))} /> + onChange((p) => withDefaultAt(p, scopeId, name, undefined))} title="Remove the default" /> +
+
+ ); + })} +
+ + + +
+ {error &&

{error}

} +
+ ); +} + +/** + * Presets: the value each setting starts with for users who haven't + * changed it themselves. Every option on Settings > Accessibility can be + * preset here, including the ones that can't be locked. + */ +export function PolicyDefaultsSection({ policy, onChange }: SectionProps) { + const [scopeId, setScopeId] = useScopeId(policy); + const categories = [...new Set(PRESETTABLE_SETTINGS.map((s) => s.category))]; + return ( + + + {categories.map((category) => { + const settings = PRESETTABLE_SETTINGS.filter((s) => s.category === category); + const groups = [...new Set(settings.map((s) => s.group ?? ''))]; + return ( +
+

{category}

+ {category === 'Accessibility' && ( +

+ The options on Settings > Accessibility, in the same order. Only single-key shortcuts, Confirm before sending and a + minimum notification duration can also be locked. +

+ )} + {groups.map((name) => ( +
+ {name &&

{name}

} + {settings.filter((s) => (s.group ?? '') === name).map((setting) => ( + + ))} +
+ ))} +
+ ); + })} + +
+ ); +} diff --git a/lib/admin/__tests__/policy-settings.test.ts b/lib/admin/__tests__/policy-settings.test.ts new file mode 100644 index 000000000..140d22e1e --- /dev/null +++ b/lib/admin/__tests__/policy-settings.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +vi.mock('@/lib/browser-navigation', () => ({ apiFetch: vi.fn() })); + +import { adminPolicyPatch, useSettingsStore } from '@/stores/settings-store'; +import { ACCESSIBILITY_ALLOWED, ACCESSIBILITY_KEYS, UNLOCKABLE_SETTING_KEYS } from '@/lib/accessibility-settings'; +import { RESTRICTION_KEYS, restrictionKeysForPage } from '@/lib/admin/restriction-keys'; +import { + ACCESSIBILITY_POLICY_SETTINGS, + PRESETTABLE_SETTINGS, + defaultAt, + effectiveDefault, + otherDefaultKeys, + parseFreeSettingValue, + restrictionAt, + withDefaultAt, + withRestrictionAt, +} from '@/lib/admin/policy-settings'; +import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types'; + +// The User policy page presets and locks settings with values users' +// browsers accept: the same keys, labels and values as the settings store +// and the user's own Settings > Accessibility page (AL-11, AL-12). + +const en = JSON.parse(readFileSync(join(process.cwd(), 'locales/en/common.json'), 'utf-8')); +const lookup = (path: string) => path.split('.').reduce((node, key) => (node as Record)?.[key], en); +const builtIn = useSettingsStore.getState() as unknown as Record; + +/** What the store applies for this policy, for a user who never chose the setting. */ +function applied(key: string, policy: Partial, current: unknown = Symbol('unset')): unknown { + const patch = adminPolicyPatch({ explicitSettings: [], [key]: current }, policy); + return Object.prototype.hasOwnProperty.call(patch, key) ? patch[key] : undefined; +} + +describe('the accessibility defaults catalog', () => { + it('lists every accessibility option and text size, once each', () => { + const keys = ACCESSIBILITY_POLICY_SETTINGS.map((s) => s.key); + expect(new Set(keys).size).toBe(keys.length); + for (const key of [...ACCESSIBILITY_KEYS, 'fontSize']) expect(keys).toContain(key); + expect(ACCESSIBILITY_POLICY_SETTINGS.every((s) => s.category === 'Accessibility')).toBe(true); + }); + + it('uses the labels users see in their settings', () => { + for (const setting of ACCESSIBILITY_POLICY_SETTINGS) { + expect(setting.labelKey, setting.key).toBeTruthy(); + expect(setting.label, setting.key).toBe(lookup(setting.labelKey!)); + } + }); + + it('offers exactly the values each option accepts', () => { + for (const setting of ACCESSIBILITY_POLICY_SETTINGS) { + const allowed = ACCESSIBILITY_ALLOWED[setting.key]; + if (!allowed) continue; + expect(setting.type, setting.key).toBe('enum'); + expect(new Set(setting.options!.map((o) => o.value)), setting.key).toEqual(new Set(allowed)); + } + }); +}); + +describe('every setting the defaults editor offers', () => { + it('has the build default the settings store starts with', () => { + for (const setting of PRESETTABLE_SETTINGS) { + expect(setting.defaultValue, setting.key).toEqual(builtIn[setting.key]); + } + }); + + it('only offers values the store applies as a default', () => { + for (const setting of PRESETTABLE_SETTINGS) { + for (const option of setting.options ?? []) { + expect(applied(setting.key, { defaults: { [setting.key]: option.value } }), `${setting.key}=${String(option.value)}`).toEqual(option.value); + } + } + // And a value it doesn't know is not applied. + expect(applied('contrast', { defaults: { contrast: 'max' } })).toBeUndefined(); + }); +}); + +describe('the lock list (lib/admin/restriction-keys.ts)', () => { + const policyKeys = restrictionKeysForPage('policy'); + + it('offers no lock for text size, contrast, motion, fonts or reading aids', () => { + for (const def of RESTRICTION_KEYS) { + const modes = def.modes ?? ['locked', 'hidden']; + if (UNLOCKABLE_SETTING_KEYS.has(def.key)) expect(modes, def.key).not.toContain('locked'); + } + const accessibility = policyKeys.filter((def) => def.category === 'Accessibility').map((def) => def.key); + expect(accessibility.sort()).toEqual(['sendConfirmation', 'singleKeyShortcuts', 'toastDuration']); + expect(policyKeys.find((def) => def.key === 'toastDuration')?.modes).toEqual(['min']); + }); + + it('has the store\'s defaults and values', () => { + for (const def of policyKeys) { + expect(def.defaultValue, def.key).toEqual(builtIn[def.key]); + for (const value of def.allowedValues ?? []) { + expect(applied(def.key, { restrictions: { [def.key]: { locked: true, value } } }), `${def.key}=${value}`).toBe(value); + } + } + }); + + it('lists the values of the store\'s types', () => { + // The store checks only the type of these, so the list has to match + // the unions in stores/settings-store.ts by hand. + const expected: Record = { + density: ['extra-compact', 'compact', 'regular', 'comfortable'], + interfaceLayout: ['default', 'mountain-view'], + deleteAction: ['trash', 'trash-and-read', 'permanent'], + mailLayout: ['split', 'focus', 'horizontal'], + externalContentPolicy: ['ask', 'block', 'allow'], + defaultReplyMode: ['reply', 'replyAll'], + replyIdentityMatch: ['exact', 'domain'], + }; + for (const def of policyKeys.filter((d) => d.type === 'enum')) { + expect(new Set(def.allowedValues), def.key).toEqual(new Set(expected[def.key])); + } + }); + + it('a lock with a value sets it for everyone; a minimum raises nothing in the store', () => { + expect(applied('singleKeyShortcuts', { restrictions: { singleKeyShortcuts: { locked: true, value: false } } }, true)).toBe(false); + expect(applied('toastDuration', { restrictions: { toastDuration: { min: 20 } }, defaults: { toastDuration: 5 } }, 10)).toBeUndefined(); + }); +}); + +const policy = (patch: Partial = {}): SettingsPolicy => ({ + ...DEFAULT_POLICY, + restrictions: {}, + defaults: {}, + scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {} }], + ...patch, +}); + +describe('edits at a scope', () => { + it('locks for Everyone with a value and drops the value with the lock', () => { + let p = withRestrictionAt(policy(), '', 'singleKeyShortcuts', { locked: true, value: false }); + expect(p.restrictions.singleKeyShortcuts).toEqual({ locked: true, value: false }); + p = withRestrictionAt(p, '', 'sendConfirmation', { hidden: true }); + p = withRestrictionAt(p, '', 'sendConfirmation', { hidden: true, locked: true, value: true }); + p = withRestrictionAt(p, '', 'sendConfirmation', { ...p.restrictions.sendConfirmation, locked: false }); + expect(p.restrictions.sendConfirmation).toEqual({ hidden: true }); + p = withRestrictionAt(p, '', 'singleKeyShortcuts', { locked: false }); + expect(p.restrictions).not.toHaveProperty('singleKeyShortcuts'); + }); + + it('keeps a minimum without a lock', () => { + const p = withRestrictionAt(policy(), '', 'toastDuration', { min: 20 }); + expect(p.restrictions.toastDuration).toEqual({ min: 20 }); + expect(withRestrictionAt(p, '', 'toastDuration', { min: undefined }).restrictions).not.toHaveProperty('toastDuration'); + }); + + it('writes a scope\'s own lock, unlocks with {} and inherits again', () => { + let p = withRestrictionAt(policy(), '', 'singleKeyShortcuts', { locked: true, value: false }); + expect(restrictionAt(p, 'team', 'singleKeyShortcuts')).toEqual({ value: { locked: true, value: false }, own: false, everyone: { locked: true, value: false } }); + p = withRestrictionAt(p, 'team', 'singleKeyShortcuts', { locked: false, value: false }); + expect(p.scopes![0].policy.restrictions).toEqual({ singleKeyShortcuts: {} }); + expect(p.restrictions.singleKeyShortcuts).toEqual({ locked: true, value: false }); + p = withRestrictionAt(p, 'team', 'singleKeyShortcuts', undefined); + expect(p.scopes![0].policy.restrictions).toEqual({}); + }); + + it('sets and removes defaults for Everyone and for a scope', () => { + let p = withDefaultAt(policy(), '', 'fontSize', 'large'); + expect(p.defaults).toEqual({ fontSize: 'large' }); + expect(defaultAt(p, 'team', 'fontSize')).toEqual({ value: 'large', own: false, everyone: 'large' }); + p = withDefaultAt(p, 'team', 'fontSize', 'huge'); + expect(p.scopes![0].policy.defaults).toEqual({ fontSize: 'huge' }); + expect(effectiveDefault(p, 'team', 'fontSize')).toBe('huge'); + expect(effectiveDefault(p, '', 'contrast')).toBe('system'); + p = withDefaultAt(p, 'team', 'fontSize', undefined); + expect(defaultAt(p, 'team', 'fontSize').own).toBe(false); + expect(p.defaults).toEqual({ fontSize: 'large' }); + }); + + it('finds presets the editor has no row for, and reads typed values', () => { + const p = policy({ defaults: { fontSize: 'large', dateFormat: 'iso' } }); + expect(otherDefaultKeys(p, '')).toEqual(['dateFormat']); + expect(parseFreeSettingValue('true')).toBe(true); + expect(parseFreeSettingValue('12')).toBe(12); + expect(parseFreeSettingValue('iso')).toBe('iso'); + }); +}); diff --git a/lib/admin/policy-settings.ts b/lib/admin/policy-settings.ts new file mode 100644 index 000000000..5cb29686c --- /dev/null +++ b/lib/admin/policy-settings.ts @@ -0,0 +1,385 @@ +/** + * The settings an admin can preset (policy `defaults`) and lock with a value + * (restriction `value` / `min`) on the User policy page, and the pure edits + * the page makes to a policy, for Everyone or for one domain or group scope. + * + * Labels are the ones users see in their own settings (English, like the + * rest of the admin panel), so the admin and the user talk about the same + * thing. lib/admin/__tests__/policy-settings.test.ts checks them against + * locales/en/common.json and the defaults against the settings store. + */ + +import type { SettingRestriction, SettingsPolicy } from './types'; +import { RESTRICTION_KEYS } from './restriction-keys'; +import { + ACCESSIBILITY_DEFAULTS, + FONT_SIZES, + MAIL_MIN_FONT_SIZES, + MAIL_TEXT_SCALES, + READ_ALOUD_RATES, + TOAST_DURATIONS, + UNLOCKABLE_SETTING_KEYS, +} from '@/lib/accessibility-settings'; + +export type PolicySettingValue = boolean | number | string; + +export interface PolicySettingOption { + value: PolicySettingValue; + label: string; +} + +export interface PresettableSetting { + key: string; + label: string; + category: string; + /** Sub-heading inside the category (the Accessibility page's sections). */ + group?: string; + type: 'boolean' | 'number' | 'enum' | 'text'; + options?: PolicySettingOption[]; + /** The build's default, what users get when nothing is preset. */ + defaultValue: PolicySettingValue; + /** Where the label comes from in locales//common.json (checked by a test). */ + labelKey?: string; +} + +const A = 'settings.accessibility'; +const SYSTEM_ON_OFF: PolicySettingOption[] = [ + { value: 'system', label: 'System' }, + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, +]; +const FONT_SIZE_LABELS: Record = { + small: '87.5%', medium: '100%', large: '112.5%', 'x-large': '125%', 'xx-large': '150%', 'xxx-large': '175%', huge: '200%', +}; +const COMPOSE_FONT_SIZES = ['14px', '16px', '18px', '20px', '24px', '32px']; + +type Entry = Omit & { defaultValue?: PolicySettingValue }; + +function group(name: string, entries: Entry[]): PresettableSetting[] { + return entries.map((entry) => ({ + category: 'Accessibility', + group: name, + defaultValue: (ACCESSIBILITY_DEFAULTS as unknown as Record)[entry.key], + ...entry, + } as PresettableSetting)); +} + +/** + * Every option on Settings > Accessibility that is the accessibility + * page's own (ACCESSIBILITY_KEYS plus text size), and Confirm before + * sending, which lives there too. In the order and sections users see. + */ +export const ACCESSIBILITY_POLICY_SETTINGS: readonly PresettableSetting[] = [ + ...group('Accessibility button', [ + { key: 'showAccessibilityButton', label: 'Accessibility button in the navigation bar', type: 'boolean', labelKey: `${A}.quick_button.label` }, + ]), + ...group('Text and reading', [ + { + key: 'fontSize', label: 'Text size', type: 'enum', labelKey: `${A}.text_size.label`, defaultValue: 'medium', + options: FONT_SIZES.map((size) => ({ value: size, label: FONT_SIZE_LABELS[size] })), + }, + { + key: 'appFont', label: 'Font', type: 'enum', labelKey: `${A}.font.label`, + options: [ + { value: 'default', label: 'Theme font' }, + { value: 'atkinson', label: 'Atkinson Hyperlegible' }, + { value: 'lexend', label: 'Lexend' }, + { value: 'opendyslexic', label: 'OpenDyslexic' }, + ], + }, + { + key: 'lineSpacing', label: 'Line spacing', type: 'enum', labelKey: `${A}.line_spacing.label`, + options: [{ value: 'normal', label: 'Normal' }, { value: 'relaxed', label: 'More' }, { value: 'loose', label: 'Most' }], + }, + { + key: 'letterSpacing', label: 'Letter spacing', type: 'enum', labelKey: `${A}.letter_spacing.label`, + options: [{ value: 'normal', label: 'Normal' }, { value: 'wide', label: 'Wide' }, { value: 'wider', label: 'Wider' }], + }, + { + key: 'wordSpacing', label: 'Word spacing', type: 'enum', labelKey: `${A}.word_spacing.label`, + options: [{ value: 'normal', label: 'Normal' }, { value: 'wide', label: 'Wide' }], + }, + { + key: 'paragraphSpacing', label: 'Paragraph spacing', type: 'enum', labelKey: `${A}.paragraph_spacing.label`, + options: [{ value: 'normal', label: 'Normal' }, { value: 'wide', label: 'Wide' }], + }, + { + key: 'readingWidth', label: 'Reading width', type: 'enum', labelKey: `${A}.reading_width.label`, + options: [{ value: 'full', label: 'Full width' }, { value: 'comfortable', label: 'About 80 characters' }], + }, + { key: 'readingMask', label: 'Reading mask', type: 'boolean', labelKey: `${A}.reading_mask.label` }, + { key: 'plainLanguage', label: 'Plain language', type: 'boolean', labelKey: `${A}.plain_language.label` }, + ]), + ...group('Colour and contrast', [ + { + key: 'contrast', label: 'Contrast', type: 'enum', labelKey: `${A}.contrast.label`, + options: [{ value: 'system', label: 'System' }, { value: 'more', label: 'More' }, { value: 'normal', label: 'Normal' }], + }, + { key: 'reduceTransparency', label: 'Reduce transparency', type: 'enum', options: SYSTEM_ON_OFF, labelKey: `${A}.transparency.label` }, + { key: 'underlineLinks', label: 'Underline links', type: 'boolean', labelKey: `${A}.underline_links.label` }, + { key: 'showColorNames', label: 'Show names next to colours', type: 'boolean', labelKey: `${A}.color_names.label` }, + { key: 'colorBlindPalette', label: 'Colour-blind-safe colours', type: 'boolean', labelKey: `${A}.cvd_palette.label` }, + ]), + ...group('Motion', [ + { key: 'reduceMotion', label: 'Reduce motion', type: 'enum', options: SYSTEM_ON_OFF, labelKey: `${A}.reduce_motion.label` }, + { key: 'pauseAnimatedImages', label: 'Pause animated images', type: 'enum', options: SYSTEM_ON_OFF, labelKey: `${A}.pause_images.label` }, + ]), + ...group('Keyboard and focus', [ + { key: 'singleKeyShortcuts', label: 'Single-key shortcuts', type: 'boolean', labelKey: `${A}.single_key_shortcuts.label` }, + { + key: 'focusRing', label: 'Focus outline', type: 'enum', labelKey: `${A}.focus_ring.label`, + options: [{ value: 'normal', label: 'Normal' }, { value: 'thick', label: 'Thick' }, { value: 'extra', label: 'Extra thick' }], + }, + { key: 'focusAlwaysVisible', label: 'Always show focus', type: 'boolean', labelKey: `${A}.focus_always.label` }, + ]), + ...group('Pointer and touch', [ + { key: 'largeTargets', label: 'Large targets', type: 'enum', options: SYSTEM_ON_OFF, labelKey: `${A}.large_targets.label` }, + { key: 'largeCursor', label: 'Large mouse pointer', type: 'boolean', labelKey: `${A}.large_cursor.label` }, + { key: 'openOnHover', label: 'Open on hover', type: 'boolean', labelKey: `${A}.open_on_hover.label` }, + { key: 'showRowActionButton', label: 'Actions button on each message', type: 'boolean', labelKey: `${A}.row_action_button.label` }, + { key: 'nativeScrollbars', label: 'Wider scrollbars', type: 'boolean', labelKey: `${A}.native_scrollbars.label` }, + ]), + ...group('Mail', [ + { + key: 'mailViewMode', label: 'Show messages as', type: 'enum', labelKey: `${A}.mail_view.label`, + options: [{ value: 'html', label: 'Formatted' }, { value: 'plain', label: 'Plain text' }], + }, + { + key: 'mailTextScale', label: 'Message text size', type: 'enum', labelKey: `${A}.mail_text_size.label`, + options: MAIL_TEXT_SCALES.map((scale) => ({ value: scale, label: scale === 0 ? 'Same as the app' : `${Math.round(scale * 100)} %` })), + }, + { + key: 'mailMinFontSize', label: 'Smallest text in messages', type: 'enum', labelKey: `${A}.mail_min_font_size.label`, + options: MAIL_MIN_FONT_SIZES.map((size) => ({ value: size, label: size === 0 ? 'No minimum' : `${size} px` })), + }, + { + key: 'mailOverrideStyles', label: 'Use my font and colours in messages', type: 'enum', labelKey: `${A}.mail_override.label`, + options: [{ value: 'never', label: 'Never' }, { value: 'high-contrast', label: 'With more contrast' }, { value: 'always', label: 'Always' }], + }, + { key: 'mailFitWidth', label: 'Fit wide messages to the window', type: 'boolean', labelKey: `${A}.mail_fit_width.label` }, + { + key: 'readAloudRate', label: 'Read aloud speed', type: 'enum', labelKey: `${A}.read_aloud_rate.label`, + options: READ_ALOUD_RATES.map((rate) => ({ value: rate, label: `${rate}×` })), + }, + { key: 'readAloudVoice', label: 'Read aloud voice', type: 'text', labelKey: `${A}.read_aloud_voice.label` }, + { + key: 'composeFontFamily', label: 'Font for new messages', type: 'enum', labelKey: `${A}.compose_font.label`, + options: [ + { value: '', label: 'Default' }, + { value: 'sans-serif', label: 'Sans serif' }, + { value: 'serif', label: 'Serif' }, + { value: 'monospace', label: 'Monospace' }, + ], + }, + { + key: 'composeFontSize', label: 'Text size for new messages', type: 'enum', labelKey: `${A}.compose_size.label`, + options: [{ value: '', label: 'Default' }, ...COMPOSE_FONT_SIZES.map((size) => ({ value: size, label: size }))], + }, + ]), + ...group('Timing and mistakes', [ + { + key: 'toastDuration', label: 'Notification duration', type: 'enum', labelKey: `${A}.toast_duration.label`, + options: TOAST_DURATIONS.map((seconds) => ({ value: seconds, label: seconds === 0 ? 'Until I close them' : `${seconds} seconds` })), + }, + { key: 'sendConfirmation', label: 'Confirm before sending', type: 'boolean', labelKey: `${A}.confirm_send.label`, defaultValue: false }, + { key: 'confirmDelete', label: 'Confirm before deleting', type: 'boolean', labelKey: `${A}.confirm_delete.label` }, + { key: 'confirmArchive', label: 'Confirm before archiving', type: 'boolean', labelKey: `${A}.confirm_archive.label` }, + ]), + ...group('Alerts and distractions', [ + { key: 'visualAlerts', label: 'Visual alerts', type: 'boolean', labelKey: `${A}.visual_alerts.label` }, + { + key: 'unreadCountStyle', label: 'Unread counts', type: 'enum', labelKey: `${A}.unread_counts.label`, + options: [{ value: 'count', label: 'Number' }, { value: 'dot', label: 'Dot' }, { value: 'hidden', label: 'Hidden' }], + }, + { key: 'doNotDisturb', label: 'Do not disturb', type: 'boolean', labelKey: `${A}.do_not_disturb.label` }, + ]), + ...group('Screen readers', [ + { key: 'announceNewMail', label: 'Announce new mail', type: 'boolean', labelKey: `${A}.announce_new_mail.label` }, + { key: 'announceRowPreview', label: 'Read the preview in the list', type: 'boolean', labelKey: `${A}.announce_preview.label` }, + { key: 'simpleMode', label: 'Simple mode', type: 'boolean', labelKey: `${A}.simple_mode.label` }, + { key: 'simpleModePagedList', label: 'Page through the message list', type: 'boolean', labelKey: `${A}.simple_mode_paged.label` }, + ]), +]; + +/** Readable choices for restriction keys whose values are numbers with a meaning. */ +const RESTRICTION_KEY_OPTIONS: Record = { + markAsReadDelay: [ + { value: 0, label: 'Instantly' }, + { value: 3000, label: 'After 3 seconds' }, + { value: 5000, label: 'After 5 seconds' }, + { value: -1, label: 'Never' }, + ], +}; + +const accessibilityKeys = new Set(ACCESSIBILITY_POLICY_SETTINGS.map((s) => s.key)); + +/** + * Everything the defaults editor lists: the Accessibility options, then the + * other settings of the User policy page's lock list, by category. + */ +export const PRESETTABLE_SETTINGS: readonly PresettableSetting[] = [ + ...ACCESSIBILITY_POLICY_SETTINGS, + ...RESTRICTION_KEYS + .filter((def) => def.page === 'policy' && !accessibilityKeys.has(def.key) && def.defaultValue !== undefined) + .map((def): PresettableSetting => { + const options = RESTRICTION_KEY_OPTIONS[def.key] + ?? def.allowedValues?.map((value) => ({ value, label: value })); + return { + key: def.key, + label: def.label, + category: def.category, + type: options ? 'enum' : def.type === 'enum' ? 'text' : def.type, + options, + defaultValue: def.defaultValue as PolicySettingValue, + }; + }), +]; + +const BY_KEY = new Map(PRESETTABLE_SETTINGS.map((s) => [s.key, s])); + +export function presettableSetting(key: string): PresettableSetting | undefined { + return BY_KEY.get(key); +} + +/** + * Whether the admin panel offers a lock for `key`. Text size, contrast, + * motion, fonts and the reading aids can only be preset. + */ +export function isLockableSetting(key: string): boolean { + return !UNLOCKABLE_SETTING_KEYS.has(key); +} + +/** "On", "Large", "20 seconds": a value as the admin reads it. */ +export function describeSettingValue(key: string, value: unknown): string { + const def = presettableSetting(key); + const option = def?.options?.find((o) => o.value === value); + if (option) return option.label; + if (typeof value === 'boolean') return value ? 'On' : 'Off'; + if (value === '' || value === undefined) return 'not set'; + return String(value); +} + +/** + * Turns what the admin typed for a setting the editor has no input for into + * a policy value: true/false, a number, or the text as it is. + */ +export function parseFreeSettingValue(text: string): PolicySettingValue { + const trimmed = text.trim(); + if (trimmed === 'true') return true; + if (trimmed === 'false') return false; + if (trimmed !== '' && Number.isFinite(Number(trimmed))) return Number(trimmed); + return text; +} + +// --------------------------------------------------------------------------- +// Edits at a scope. '' is Everyone (the global policy); any other id is one +// of policy.scopes. A scope value replaces Everyone's per key; removing it +// in a scope means "use Everyone's" again. +// --------------------------------------------------------------------------- + +export const EVERYONE_SCOPE = ''; + +export interface ScopedValue { + /** What applies at the scope: its own value, or Everyone's. */ + value: T; + /** The scope sets it itself (always true for Everyone when set at all). */ + own: boolean; + /** Everyone's value. */ + everyone: T; +} + +function clonePolicy(policy: SettingsPolicy): SettingsPolicy { + return JSON.parse(JSON.stringify(policy)) as SettingsPolicy; +} + +function scopeOf(policy: SettingsPolicy, scopeId: string) { + return scopeId === EVERYONE_SCOPE ? null : (policy.scopes ?? []).find((s) => s.id === scopeId) ?? null; +} + +const hasOwn = (obj: object | undefined, key: string) => !!obj && Object.prototype.hasOwnProperty.call(obj, key); + +export function restrictionAt(policy: SettingsPolicy, scopeId: string, key: string): ScopedValue { + const everyone = policy.restrictions?.[key]; + const scope = scopeOf(policy, scopeId); + if (!scope) return { value: everyone, own: everyone !== undefined, everyone }; + const restrictions = scope.policy.restrictions; + return hasOwn(restrictions, key) + ? { value: restrictions![key], own: true, everyone } + : { value: everyone, own: false, everyone }; +} + +function isEmptyRestriction(r: SettingRestriction | undefined): boolean { + return !r || (!r.locked && !r.hidden && !r.allowedValues?.length && r.min === undefined && r.max === undefined); +} + +/** + * The policy with `key`'s restriction set at the scope. `undefined` removes + * it (in a scope: back to Everyone's); an empty restriction in a scope is + * kept as `{}`, which unlocks what Everyone locks. A restriction that is no + * longer locked drops its value, so an old value can't come back with the + * next lock. + */ +export function withRestrictionAt( + policy: SettingsPolicy, + scopeId: string, + key: string, + restriction: SettingRestriction | undefined, +): SettingsPolicy { + const next = clonePolicy(policy); + let value = restriction ? { ...restriction } : undefined; + if (value && !value.locked) { + delete value.locked; + delete value.value; + } + if (value && !value.hidden) delete value.hidden; + if (value && value.min === undefined) delete value.min; + const empty = isEmptyRestriction(value); + if (empty && value) value = {}; + const scope = scopeOf(next, scopeId); + if (!scope) { + next.restrictions = { ...(next.restrictions ?? {}) }; + if (!value || empty) delete next.restrictions[key]; + else next.restrictions[key] = value; + return next; + } + const restrictions = (scope.policy.restrictions ??= {}); + if (value === undefined) delete restrictions[key]; + else restrictions[key] = value; + return next; +} + +export function defaultAt(policy: SettingsPolicy, scopeId: string, key: string): ScopedValue { + const everyone = hasOwn(policy.defaults, key) ? policy.defaults[key] : undefined; + const scope = scopeOf(policy, scopeId); + if (!scope) return { value: everyone, own: everyone !== undefined, everyone }; + const defaults = scope.policy.defaults; + return hasOwn(defaults, key) + ? { value: defaults![key], own: true, everyone } + : { value: everyone, own: false, everyone }; +} + +/** The policy with `key` preset to `value` at the scope; `undefined` removes the preset. */ +export function withDefaultAt(policy: SettingsPolicy, scopeId: string, key: string, value: PolicySettingValue | undefined): SettingsPolicy { + const next = clonePolicy(policy); + const scope = scopeOf(next, scopeId); + const target = scope ? (scope.policy.defaults ??= {}) : (next.defaults = { ...(next.defaults ?? {}) }); + if (value === undefined) delete target[key]; + else target[key] = value; + return next; +} + +/** Keys preset at the scope (its own, not Everyone's) that the editor has no row for. */ +export function otherDefaultKeys(policy: SettingsPolicy, scopeId: string): string[] { + const scope = scopeOf(policy, scopeId); + const defaults = scope ? scope.policy.defaults ?? {} : policy.defaults ?? {}; + return Object.keys(defaults).filter((key) => !BY_KEY.has(key)); +} + +/** + * What users get for `key` at the scope when nothing else applies: the + * preset (the scope's or Everyone's), else the build default. + */ +export function effectiveDefault(policy: SettingsPolicy, scopeId: string, key: string): unknown { + const preset = defaultAt(policy, scopeId, key).value; + return preset !== undefined ? preset : presettableSetting(key)?.defaultValue; +} diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts index e87a01e48..88cdd98cf 100644 --- a/lib/admin/restriction-keys.ts +++ b/lib/admin/restriction-keys.ts @@ -18,16 +18,23 @@ export interface RestrictionKeyDef { allowedValues?: string[]; /** The admin page that lists it. */ page: AdminPageId; - /** Which of locked, hidden and allowed values make sense for it. */ - modes?: ReadonlyArray<'locked' | 'hidden' | 'allowedValues'>; + /** + * Which of locked, hidden, allowed values and a minimum make sense for + * it. A lock always comes with the value users get (`restriction.value`); + * `min` keeps users at or above a number (`restriction.min`). + */ + modes?: ReadonlyArray<'locked' | 'hidden' | 'allowedValues' | 'min'>; + /** The build's default for the setting (stores/settings-store.ts), for the User policy page. */ + defaultValue?: boolean | number | string; } export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [ // Text size, motion, contrast and the other reading aids are left out on // purpose: an admin can preset them (policy defaults) but not lock or hide - // them, see UNLOCKABLE_SETTING_KEYS in lib/accessibility-settings.ts. - { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['extra-compact', 'compact', 'regular', 'comfortable'], page: 'policy' }, - { key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'], page: 'policy' }, + // them, see UNLOCKABLE_SETTING_KEYS in lib/accessibility-settings.ts. The + // presets live in lib/admin/policy-settings.ts. + { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['extra-compact', 'compact', 'regular', 'comfortable'], page: 'policy', defaultValue: 'regular' }, + { key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'], page: 'policy', defaultValue: 'default' }, { key: 'singleKeyShortcuts', label: 'Single-key shortcuts', @@ -36,31 +43,41 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [ type: 'boolean', page: 'policy', modes: ['locked'], + defaultValue: true, }, { key: 'toastDuration', label: 'Notification duration', - description: 'How long messages such as "Moved to Archive" with their Undo button stay, in seconds (0 = until closed). With a minimum, users can make them stay longer but not shorter.', + description: 'A minimum for how long messages such as "Moved to Archive" with their Undo button stay. Users can make them stay longer, not shorter.', category: 'Accessibility', type: 'number', page: 'policy', - modes: ['locked'], + modes: ['min'], + defaultValue: 5, }, - { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number', page: 'policy' }, - { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'], page: 'policy' }, - { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean', page: 'policy' }, - { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'], page: 'policy' }, - { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number', page: 'policy' }, - { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'], page: 'policy' }, - { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean', page: 'policy' }, - { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'replyAll'], page: 'policy' }, - { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean', page: 'policy' }, - { key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'], page: 'policy' }, - { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean', page: 'policy' }, - { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean', page: 'policy' }, - { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean', page: 'policy' }, - { key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean', page: 'policy' }, - { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean', page: 'policy' }, + { + key: 'sendConfirmation', + label: 'Confirm before sending', + description: 'Lock: users are asked before a message goes out, or not, as set here. Hide removes the option from their settings.', + category: 'Accessibility', + type: 'boolean', + page: 'policy', + defaultValue: false, + }, + { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number', page: 'policy', defaultValue: 0 }, + { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'], page: 'policy', defaultValue: 'trash' }, + { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean', page: 'policy', defaultValue: true }, + { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'], page: 'policy', defaultValue: 'split' }, + { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number', page: 'policy', defaultValue: 50 }, + { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'], page: 'policy', defaultValue: 'ask' }, + { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'replyAll'], page: 'policy', defaultValue: 'reply' }, + { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean', page: 'policy', defaultValue: false }, + { key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'], page: 'policy', defaultValue: 'domain' }, + { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean', page: 'policy', defaultValue: false }, + { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean', page: 'policy', defaultValue: true }, + { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean', page: 'policy', defaultValue: true }, + { key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean', page: 'policy', defaultValue: false }, + { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean', page: 'policy', defaultValue: false }, { key: 'signature', label: 'Signatures',