feat: let admins lock a setting to a chosen value and preset any setting, accessibility options included

This commit is contained in:
Linus Rath
2026-10-29 18:27:35 +01:00
parent 4aa8d09198
commit 58f01fe7a3
6 changed files with 1182 additions and 83 deletions
@@ -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<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
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<HTMLElement>(`[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<string, unknown>; defaults: Record<string, unknown>; scopes?: Array<{ policy: Record<string, unknown> }> };
}
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(<PolicyPage />);
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(<PolicyPage />);
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(<PolicyPage />);
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(<PolicyPage />);
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(<PolicyPage />);
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(<PolicyPage />);
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(<PolicyPage />);
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' });
});
});
+8 -61
View File
@@ -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<Record<keyof FeatureGates, { label: string; d
tagsAboveSharedEnabled: { label: 'Tags Above Shared Mailboxes', description: 'List the Tags in the sidebar above the shared mailboxes instead of below them. Turn this on when users have many shared mailboxes and work with tags: expanded, the shared mailboxes push the tags out of sight.' },
};
const RESTRICTABLE_SETTINGS = restrictionKeysForPage('policy');
const PAGE = adminPageDef('policy');
const fieldLabelClass = 'mb-1 block text-[11px] font-medium text-muted-foreground';
const cardClass = 'rounded-md border border-border bg-muted/30 p-3';
const subLabelClass = 'mt-2 text-xs font-semibold text-muted-foreground';
/** Mirrors the sanitizer's URL rule so the admin sees the drop before saving. */
function isValidDefaultAppUrl(raw: string): boolean {
@@ -201,31 +198,9 @@ export function PolicyPage() {
setMessage(null);
}
function toggleLocked(settingKey: string) {
setPolicy(prev => {
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() {
})}
</SettingsSection>
<SettingsSection
id="restrictions"
title="Locked settings"
description="Lock a setting so users cannot change it, or hide it from their settings entirely."
>
{categories.map(category => (
<div key={category}>
<p className={subLabelClass}>{category}</p>
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {};
return (
<SettingItem key={setting.key} label={setting.label}>
<div className="flex items-center gap-3">
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" />
<Lock className="h-3 w-3" /> Lock
</label>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
className="rounded border-input" />
Hide
</label>
</div>
</SettingItem>
);
})}
</div>
))}
</SettingsSection>
<LockedSettingsSection policy={policy} onChange={changePolicy} />
<PolicyDefaultsSection policy={policy} onChange={changePolicy} />
</div>
)}
@@ -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 (
<div className="rounded-md border border-border bg-muted/30 p-3">
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Applies to">
<span className="me-1 text-xs font-medium text-muted-foreground">Applies to</span>
<button type="button" aria-pressed={scopeId === EVERYONE} className={chipClass(scopeId === EVERYONE)} onClick={() => onPick(EVERYONE)}>
Everyone
</button>
{scopes.map((s) => (
<button
key={s.id}
type="button"
aria-pressed={scopeId === s.id}
className={chipClass(scopeId === s.id)}
title={scopeMatchSummary(s)}
onClick={() => onPick(s.id)}
>
{s.label}
</button>
))}
<AdminLink href="/admin/organisation" className="ms-auto text-xs text-muted-foreground underline-offset-2 hover:underline">
Manage domains and groups
</AdminLink>
</div>
{scope && (
<p className="mt-2 text-xs text-muted-foreground">
Changes here apply to {scopeMatchSummary(scope)} only. What this scope doesn&apos;t change comes from Everyone.
</p>
)}
</div>
);
}
/** "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 (
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<span>{own ? 'Everyone' : 'From Everyone'}: {everyone}</span>
{own && (
<button type="button" onClick={onReset} className="inline-flex items-center gap-1 underline-offset-2 hover:underline">
<RotateCcw className="h-3 w-3" /> Use Everyone&apos;s value
</button>
)}
</p>
);
}
/**
* 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 <ToggleSwitch checked={value === true} onChange={onChange} ariaLabel={ariaLabel} />;
}
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 (
<Select
value={String(value ?? '')}
ariaLabel={ariaLabel}
onChange={(raw) => {
const option = setting.options!.find((o) => String(o.value) === raw);
if (option) onChange(option.value);
}}
options={options}
/>
);
}
if (setting.type === 'number') {
return (
<input
type="number"
aria-label={ariaLabel}
value={typeof value === 'number' ? value : ''}
onChange={(e) => { if (e.target.value !== '' && Number.isFinite(Number(e.target.value))) onChange(Number(e.target.value)); }}
className={cn(inputClass, 'w-28')}
/>
);
}
return (
<input
type="text"
aria-label={ariaLabel}
autoComplete="off"
spellCheck={false}
value={typeof value === 'string' ? value : ''}
onChange={(e) => 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 (
<SettingItem label={def.label} description={def.description}>
<div className="flex flex-col items-start gap-2 sm:items-end">
<div className="flex flex-wrap items-center gap-3">
{modes.includes('locked') && (
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
className="rounded border-input"
checked={!!current.locked}
onChange={(e) => set(e.target.checked ? { locked: true, value: current.value ?? lockTo() } : { locked: false })}
/>
<Lock className="h-3 w-3" /> Lock
</label>
)}
{modes.includes('locked') && current.locked && setting && current.value !== undefined && (
<SettingValueInput
setting={setting}
value={current.value}
ariaLabel={`Value users get for ${def.label}`}
onChange={(v) => set({ value: v })}
/>
)}
{modes.includes('min') && (
<Select
value={typeof current.min === 'number' ? String(current.min) : ''}
ariaLabel={`Minimum for ${def.label}`}
onChange={(raw) => set({ min: raw === '' ? undefined : Number(raw) })}
options={[
{ value: '', label: 'No minimum' },
...TOAST_MINIMUMS.map((seconds) => ({ value: String(seconds), label: `At least ${seconds} seconds` })),
...(typeof current.min === 'number' && !TOAST_MINIMUMS.includes(current.min)
? [{ value: String(current.min), label: `At least ${current.min} seconds` }]
: []),
]}
/>
)}
{modes.includes('hidden') && (
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input type="checkbox" className="rounded border-input" checked={!!current.hidden} onChange={(e) => set({ hidden: e.target.checked })} />
Hide
</label>
)}
</div>
{current.locked && current.value === undefined && (
// Locks from before the value picker, or from a hand-written
// policy.json, freeze whatever each user had.
<p className={cn(noteClass, 'flex flex-wrap items-center gap-1.5')}>
<span>No value: each user keeps the value they had.</span>
{lockTo() !== undefined && (
<button type="button" className="underline underline-offset-2 hover:text-foreground" onClick={() => set({ value: lockTo() })}>
Lock to {describeSettingValue(def.key, lockTo())}
</button>
)}
</p>
)}
<InheritLine
scoped={scopeId !== EVERYONE}
own={own}
everyone={describeRestriction(def.key, everyone)}
onReset={() => onChange((p) => withRestrictionAt(p, scopeId, def.key, undefined))}
/>
</div>
</SettingItem>
);
}
/**
* 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 (
<SettingsSection
id="restrictions"
title="Locked settings"
description="Lock a setting to the value you pick, so users can't change it, or hide it from their settings."
>
<ScopePicker policy={policy} scopeId={scopeId} onPick={setScopeId} />
{categories.map((category) => (
<div key={category}>
<p className={subLabelClass}>{category}</p>
{category === 'Accessibility' && <p className={cn(noteClass, 'mt-1')}>{UNLOCKABLE_NOTE}</p>}
{LOCKABLE.filter((s) => s.category === category).map((def) => (
<LockRow key={def.key} def={def} policy={policy} scopeId={scopeId} onChange={onChange} />
))}
</div>
))}
</SettingsSection>
);
}
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 (
<SettingItem
label={setting.label}
description={preset.own ? `Built-in: ${describeSettingValue(setting.key, setting.defaultValue)}` : undefined}
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex w-full items-center gap-1.5 sm:w-auto">
{preset.own && <Badge tone="accent">set</Badge>}
<SettingValueInput setting={setting} value={shown} onChange={set} />
{preset.own && <RevertButton onClick={() => set(undefined)} title={scoped ? "Use Everyone's value" : 'Remove the default'} />}
</div>
{scoped && !preset.own && preset.everyone !== undefined && (
<p className={noteClass}>From Everyone</p>
)}
{conflict && <p className="text-xs text-amber-700 dark:text-amber-400">{conflict}</p>}
</div>
</SettingItem>
);
}
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<string | null>(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 (
<div>
<p className={subLabelClass}>Other settings</p>
<p className={cn(noteClass, 'mt-1')}>
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&apos; browsers.
</p>
{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 (
<SettingItem key={name} label={name}>
<div className="flex w-full items-center gap-1.5 sm:w-auto">
<SettingValueInput setting={setting} value={value} onChange={(v) => onChange((p) => withDefaultAt(p, scopeId, name, v))} />
<RevertButton onClick={() => onChange((p) => withDefaultAt(p, scopeId, name, undefined))} title="Remove the default" />
</div>
</SettingItem>
);
})}
<div className="mt-3 flex flex-wrap items-end gap-2">
<label className="block">
<span className="mb-1 block text-[11px] font-medium text-muted-foreground">Setting key</span>
<input type="text" value={key} onChange={(e) => setKey(e.target.value)} placeholder="dateFormat" autoComplete="off" spellCheck={false}
className={cn(inputClass, 'w-48 font-mono')} />
</label>
<label className="block">
<span className="mb-1 block text-[11px] font-medium text-muted-foreground">Value</span>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} placeholder="iso" autoComplete="off" spellCheck={false}
className={cn(inputClass, 'w-40')} />
</label>
<Button type="button" variant="outline" size="sm" className="h-9 gap-1.5 text-xs" onClick={add}>
<Plus className="h-3.5 w-3.5" /> Add default
</Button>
</div>
{error && <p role="alert" className="mt-1 text-xs text-destructive">{error}</p>}
</div>
);
}
/**
* 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 (
<SettingsSection
id="defaults"
title="Defaults"
description="The value a setting starts with. It applies to every user who hasn't changed that setting themselves, and they can still change it unless it's locked."
>
<ScopePicker policy={policy} scopeId={scopeId} onPick={setScopeId} />
{categories.map((category) => {
const settings = PRESETTABLE_SETTINGS.filter((s) => s.category === category);
const groups = [...new Set(settings.map((s) => s.group ?? ''))];
return (
<div key={category} data-testid={`defaults-${category}`}>
<p className={subLabelClass}>{category}</p>
{category === 'Accessibility' && (
<p className={cn(noteClass, 'mt-1')}>
The options on Settings &gt; Accessibility, in the same order. Only single-key shortcuts, Confirm before sending and a
minimum notification duration can also be locked.
</p>
)}
{groups.map((name) => (
<div key={name || category}>
{name && <p className={groupLabelClass}>{name}</p>}
{settings.filter((s) => (s.group ?? '') === name).map((setting) => (
<DefaultRow key={setting.key} setting={setting} policy={policy} scopeId={scopeId} onChange={onChange} />
))}
</div>
))}
</div>
);
})}
<OtherDefaults policy={policy} scopeId={scopeId} onChange={onChange} />
</SettingsSection>
);
}
+181
View File
@@ -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<unknown>((node, key) => (node as Record<string, unknown>)?.[key], en);
const builtIn = useSettingsStore.getState() as unknown as Record<string, unknown>;
/** What the store applies for this policy, for a user who never chose the setting. */
function applied(key: string, policy: Partial<SettingsPolicy>, 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<string, string[]> = {
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> = {}): 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');
});
});
+385
View File
@@ -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/<code>/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<string, string> = {
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<PresettableSetting, 'category' | 'defaultValue'> & { defaultValue?: PolicySettingValue };
function group(name: string, entries: Entry[]): PresettableSetting[] {
return entries.map((entry) => ({
category: 'Accessibility',
group: name,
defaultValue: (ACCESSIBILITY_DEFAULTS as unknown as Record<string, PolicySettingValue>)[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<string, PolicySettingOption[]> = {
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<T> {
/** 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<SettingRestriction | undefined> {
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<unknown> {
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;
}
+39 -22
View File
@@ -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',