mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
386 lines
18 KiB
TypeScript
386 lines
18 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|