mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: let admins lock a setting to a chosen value and preset any setting, accessibility options included
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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'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'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' 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 > 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user