From 457ea53ca3d6c72c58908f0a758b227d5e4f8a1f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 2 Sep 2026 10:49:55 -0700 Subject: [PATCH] Let an installation seed and lock user settings The first two thirds of #207. A school wanting "warn about outside senders" on for three thousand pupils cannot ask three thousand pupils, and the reporter is right that this is a company policy rather than a preference. Two powers, and the difference between them is the whole request. `defaults` seed an account that has never had settings of its own and can be changed afterwards like anything else -- a starting point, not a rule. `enforced` are reapplied on every load and cannot be changed at all. Enforced controls stay visible and go dead, with a line saying why. The issue asked for that by name: a control that is simply missing reads as a bug to somebody who has used ihasmail without a policy. The lock is in the settings store rather than only on the controls. There is one door -- `update` -- and putting it there means an imported settings file, a settings file synced from a device that predates the policy, and a control somebody adds later and forgets to check are all covered by construction. Reset goes back to the installation's answer rather than to ihasmail's, so it cannot be a way around a policy either. Configured by environment variable or by a file, because ihasmail's own production runs read-only with no volume: an installation that cannot mount a file can still set a variable. Keys this build does not have are dropped, the same rule an imported settings file already gets -- a policy written against a newer ihasmail must not put a setting nothing reads into everybody's synced settings file. Malformed JSON stops the server rather than quietly doing nothing, since a policy that silently did not apply is indistinguishable from the feature not working. Tier three -- enforcing a setting once while still letting readers change it afterwards -- is not here. It needs a decision the reporter and I have not made yet, and it is the only part that stores anything new. Refs #207. --- README.md | 40 ++++++ server/src/app.ts | 3 + server/src/config.ts | 47 +++++++ web/src/App.tsx | 9 ++ web/src/lib/settingsPolicy.ts | 82 ++++++++++++ web/src/locales/de.ts | 1 + web/src/locales/es.ts | 1 + web/src/locales/fr.ts | 1 + web/src/locales/ja.ts | 1 + web/src/locales/nl.ts | 1 + web/src/locales/pt-BR.ts | 1 + web/src/locales/ru.ts | 1 + web/src/locales/uk.ts | 1 + web/src/locales/zh-Hans.ts | 1 + .../store/__tests__/settings-policy.test.ts | 124 ++++++++++++++++++ web/src/store/settings.ts | 44 +++++-- web/src/ui/misc.tsx | 8 +- web/src/views/settings/AppearanceSettings.tsx | 11 +- web/src/views/settings/CalendarSettings.tsx | 13 +- web/src/views/settings/GeneralSettings.tsx | 33 ++--- .../views/settings/NotificationsSettings.tsx | 3 +- web/src/views/settings/PrivacySettings.tsx | 15 ++- 22 files changed, 396 insertions(+), 45 deletions(-) create mode 100644 web/src/lib/settingsPolicy.ts create mode 100644 web/src/store/__tests__/settings-policy.test.ts diff --git a/README.md b/README.md index 19cf6c2..f5f6c68 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,46 @@ nowhere to live across a restart. Removing it means moving the session upstream into a token Stalwart itself issues and can revoke, which is what the OAuth work in [ROADMAP.md](ROADMAP.md) is for. +### Settings the installation decides + +A deployment can seed and lock user settings, which is what a school wanting +"warn about outside senders" on for three thousand pupils needs — asking three +thousand pupils is not a plan. + +```bash +-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \ +-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}' +``` + +Or, where mounting a file is easier than quoting JSON in a unit file: + +```bash +-e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json +``` + +```json +{ + "defaults": { "externalSenderBanner": true }, + "enforced": { "externalRecipientConfirm": true } +} +``` + +The two are different powers. **`defaults`** seed an account that has never had +settings of its own; the reader can change any of them afterwards, and they are +a starting point rather than a rule. **`enforced`** are reapplied on every load +and cannot be changed at all — their controls stay visible in Settings and go +dead with a line saying why, because a control that is simply missing reads as a +bug to anyone who has used ihasmail without a policy. + +Both are given in the same names and values a settings export uses, so +`Settings → General → Export` on a configured account is the quickest way to +write one. Keys this build does not have are ignored rather than stored, and +malformed JSON stops the server at startup rather than silently doing nothing. + +Enforcement is applied in the settings store rather than only on the controls, +so an imported settings file or a settings file synced from a device that +predates the policy cannot get around it. + ## Architecture ``` diff --git a/server/src/app.ts b/server/src/app.ts index f07a894..13db7f7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -175,6 +175,9 @@ export function createApp(basePath = config.basePath): Hono { sourceUrl: config.sourceUrl, imageProxy: config.imageProxy, maxUploadBytes: config.maxUploadBytes, + /* Sent before sign-in like the rest of this: it says what the + installation has decided, not anything about who is asking. */ + settingsPolicy: config.settingsPolicy, }), ); diff --git a/server/src/config.ts b/server/src/config.ts index 71afe77..21e8eab 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -111,9 +111,56 @@ export function assertImmutable(sessionFile: string, root: string): void { if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url))); +/** + * Settings an installation decides, rather than each reader. + * + * A school turning on "warn about outside senders" for three thousand pupils + * cannot ask three thousand pupils to turn it on -- issue #207. Two sections, + * which are two different powers: + * + * - `defaults` seed an account that has never had settings of its own. The + * reader can change any of them afterwards; they are a starting point, not a + * rule. + * - `enforced` are applied on every load and cannot be changed here at all. The + * controls stay visible and go dead, which the issue asked for by name: a + * missing control confuses somebody who has used ihasmail elsewhere. + * + * Read from a file or straight from the environment, because ihasmail's own + * production runs read-only with no volume -- an installation that cannot mount + * a file can still set a variable. + */ +function readSettingsPolicy(): { defaults: Record; enforced: Record } { + const parse = (raw: string, where: string): Record => { + try { + const v = JSON.parse(raw) as unknown; + if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object"); + return v as Record; + } catch (err) { + /* Loud, and fatal. A policy that silently did not apply would look like + the feature not working, and the admin would have no way to tell. */ + throw new Error(`Invalid ${where}: ${(err as Error).message}`); + } + }; + + const file = process.env.SETTINGS_POLICY_FILE; + if (file) { + if (!existsSync(file)) throw new Error(`SETTINGS_POLICY_FILE does not exist: ${file}`); + const whole = parse(readFileSync(file, "utf8"), `SETTINGS_POLICY_FILE (${file})`); + return { + defaults: (whole.defaults as Record) ?? {}, + enforced: (whole.enforced as Record) ?? {}, + }; + } + return { + defaults: process.env.SETTINGS_DEFAULTS ? parse(process.env.SETTINGS_DEFAULTS, "SETTINGS_DEFAULTS") : {}, + enforced: process.env.SETTINGS_ENFORCED ? parse(process.env.SETTINGS_ENFORCED, "SETTINGS_ENFORCED") : {}, + }; +} + export const config = { isProd, appName: env("APP_NAME", "ihasmail"), + settingsPolicy: readSettingsPolicy(), /** * What this build calls itself: `2.16.57`. Set by the image build from * `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of diff --git a/web/src/App.tsx b/web/src/App.tsx index ea0327c..0532444 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock"; import { setUnreadBadge } from "@/lib/notify"; import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings"; import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync"; +import { loadSettingsPolicy } from "@/lib/settingsPolicy"; import { listenForVerification, renewWebPush } from "@/lib/webpushEnable"; import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n"; import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges"; @@ -145,9 +146,17 @@ function AuthedApp() { } let cancelled = false; void (async () => { + /* Before the account's own settings, so both the seeding below and the + enforcement inside `hydrate` have something to apply. */ + await loadSettingsPolicy(); + if (cancelled) return; const remote = await loadRemoteSettings(); if (cancelled) return; if (remote) useSettings.getState().hydrate(remote); + // No settings file: this account has never had settings of its own, so + // the installation's defaults are what it starts on rather than + // ihasmail's. Issue #207. + else useSettings.getState().seedFromPolicy(); // The catalogue for whatever language that turned out to be. Hydrating // asks for it; this is waiting for the answer. await whenLanguageReady(); diff --git a/web/src/lib/settingsPolicy.ts b/web/src/lib/settingsPolicy.ts new file mode 100644 index 0000000..563e37d --- /dev/null +++ b/web/src/lib/settingsPolicy.ts @@ -0,0 +1,82 @@ +import { withBase } from "@/lib/basePath"; +import { DEFAULT_SETTINGS, type Settings } from "@/store/settings"; + +/** + * What the installation has decided about settings, rather than the reader. + * + * Two powers, from #207. `defaults` seed an account that has never had settings + * of its own and can be changed afterwards like anything else. `enforced` are + * applied on every load and cannot be changed here at all -- their controls stay + * visible and go dead, which is what the issue asked for: hiding them confuses + * somebody who has used ihasmail somewhere without a policy. + * + * Fetched once. `/api/config` is unauthenticated and already fetched by the + * sign-in page, so this costs nothing on a cold load and is available before + * anybody's settings are read. + */ +export interface SettingsPolicy { + defaults: Partial; + enforced: Partial; +} + +const EMPTY: SettingsPolicy = { defaults: {}, enforced: {} }; + +let policy: SettingsPolicy = EMPTY; +let fetched: Promise | null = null; + +/** + * Keys the installation names that this build does not have. + * + * A policy written against a newer ihasmail, or with a typo in it, must not + * introduce a setting that nothing reads: `update` would carry it around and + * `syncedPart` would push it to the reader's settings file for ever. Anything + * not in `DEFAULT_SETTINGS` is dropped, which is the same rule `importJson` + * already applies to a settings file somebody hands us. + */ +function known(obj: Record): Partial { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) if (k in DEFAULT_SETTINGS) out[k] = v; + return out as Partial; +} + +export async function loadSettingsPolicy(): Promise { + if (fetched) return fetched; + fetched = (async () => { + try { + const res = await fetch(withBase("/api/config"), { credentials: "same-origin" }); + if (!res.ok) return EMPTY; + const body = (await res.json()) as { settingsPolicy?: { defaults?: Record; enforced?: Record } }; + policy = { + defaults: known(body.settingsPolicy?.defaults ?? {}), + enforced: known(body.settingsPolicy?.enforced ?? {}), + }; + return policy; + } catch { + /* No policy is the ordinary case and an unreachable one must not stop a + sign-in: an installation that sets nothing looks exactly like this. */ + return EMPTY; + } + })(); + return fetched; +} + +/** What the installation has settled, for a reader who has none of their own. */ +export function policyDefaults(): Partial { + return policy.defaults; +} + +/** What the installation has settled that a reader may not change. */ +export function policyEnforced(): Partial { + return policy.enforced; +} + +/** Whether this setting belongs to the administrator rather than the reader. */ +export function isEnforced(key: keyof Settings): boolean { + return key in policy.enforced; +} + +/** Only for tests: forget what was fetched. */ +export function resetSettingsPolicyForTest(next: SettingsPolicy = EMPTY): void { + policy = { defaults: known(next.defaults as Record), enforced: known(next.enforced as Record) }; + fetched = Promise.resolve(policy); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 6680d81..5045616 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -55,6 +55,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Set for everyone here. You cannot change this.": "Für alle hier festgelegt. Sie können dies nicht ändern.", "Export iCAL file": "iCAL-Datei exportieren", "Could not export this calendar: {error}": "Dieser Kalender konnte nicht exportiert werden: {error}", // ── Actions ──────────────────────────────────────────────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 3d32615..f42c76d 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -47,6 +47,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Set for everyone here. You cannot change this.": "Definido para todos aquí. No puedes cambiarlo.", "Export iCAL file": "Exportar archivo iCAL", "Could not export this calendar: {error}": "No se pudo exportar este calendario: {error}", // ── Actions ──────────────────────────────────────────────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 93ce238..89c22e0 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -52,6 +52,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Set for everyone here. You cannot change this.": "Défini pour tout le monde ici. Vous ne pouvez pas le modifier.", "Export iCAL file": "Exporter un fichier iCAL", "Could not export this calendar: {error}": "Impossible d’exporter ce calendrier : {error}", // ── Actions ──────────────────────────────────────────────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 818156e..cc0ec07 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -46,6 +46,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Set for everyone here. You cannot change this.": "この環境全体で設定されています。変更できません。", "Export iCAL file": "iCAL ファイルをエクスポート", "Could not export this calendar: {error}": "このカレンダーをエクスポートできませんでした: {error}", // ── Actions ──────────────────────────────────────────────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index e63c944..c76055b 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -43,6 +43,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.", "Export iCAL file": "iCAL-bestand exporteren", "Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}", // ── Actions ──────────────────────────────────────────────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index ded6aeb..eeb7ad3 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -50,6 +50,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Set for everyone here. You cannot change this.": "Definido para todos aqui. Você não pode alterar isto.", "Export iCAL file": "Exportar arquivo iCAL", "Could not export this calendar: {error}": "Não foi possível exportar esta agenda: {error}", // ── Actions ──────────────────────────────────────────────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 5f188f7..4ca7c72 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -49,6 +49,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Set for everyone here. You cannot change this.": "Задано для всех здесь. Изменить нельзя.", "Export iCAL file": "Экспортировать файл iCAL", "Could not export this calendar: {error}": "Не удалось экспортировать этот календарь: {error}", // ── Actions ──────────────────────────────────────────────────────── diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index c13134d..8e43073 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -43,6 +43,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Set for everyone here. You cannot change this.": "Задано для всіх тут. Змінити не можна.", "Export iCAL file": "Експортувати файл iCAL", "Could not export this calendar: {error}": "Не вдалося експортувати цей календар: {error}", // ── Actions ──────────────────────────────────────────────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 0f58c57..972420b 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -45,6 +45,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Set for everyone here. You cannot change this.": "已为此处所有人设定,您无法更改。", "Export iCAL file": "导出 iCAL 文件", "Could not export this calendar: {error}": "无法导出此日历:{error}", // ── Actions ──────────────────────────────────────────────────────── diff --git a/web/src/store/__tests__/settings-policy.test.ts b/web/src/store/__tests__/settings-policy.test.ts new file mode 100644 index 0000000..e833826 --- /dev/null +++ b/web/src/store/__tests__/settings-policy.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SETTINGS, useSettings } from "@/store/settings"; +import { isEnforced, policyDefaults, policyEnforced, resetSettingsPolicyForTest } from "@/lib/settingsPolicy"; + +/* + * Settings an installation decides, from #207. + * + * A school turning on "warn about outside senders" for three thousand pupils + * cannot ask three thousand pupils. Two powers, and the difference between them + * is the whole point: defaults are a starting point the reader may change, + * enforced settings are not. + */ + +vi.mock("@/lib/settingsSync", () => ({ + queueSettingsPush: vi.fn(), + pendingSettingsKeys: () => new Set(), +})); + +beforeEach(() => { + resetSettingsPolicyForTest(); + useSettings.setState({ settings: { ...DEFAULT_SETTINGS } }); +}); + +afterEach(() => { + resetSettingsPolicyForTest(); + vi.restoreAllMocks(); +}); + +describe("what the installation has decided", () => { + it("keeps to the settings this build actually has", () => { + /* + * A policy written against a newer ihasmail, or with a typo in it, must not + * introduce a key nothing reads: it would be carried around and pushed to + * the reader's settings file for ever. Same rule an imported settings file + * already gets. + */ + resetSettingsPolicyForTest({ + defaults: { conversationMode: false, notARealSetting: true } as never, + enforced: { alsoNotReal: 1 } as never, + }); + expect(policyDefaults()).toEqual({ conversationMode: false }); + expect(policyEnforced()).toEqual({}); + }); + + it("says which settings belong to the administrator", () => { + // The setting the issue was actually about: the outside-sender banner. + resetSettingsPolicyForTest({ defaults: {}, enforced: { externalSenderBanner: true } as never }); + expect(isEnforced("externalSenderBanner")).toBe(true); + expect(isEnforced("conversationMode")).toBe(false); + }); +}); + +describe("defaults, for an account that has none of its own", () => { + it("seeds them", () => { + resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} }); + useSettings.getState().seedFromPolicy(); + expect(useSettings.getState().settings.conversationMode).toBe(false); + }); + + it("leaves everything it does not name alone", () => { + resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} }); + useSettings.getState().seedFromPolicy(); + expect(useSettings.getState().settings.showAvatars).toBe(DEFAULT_SETTINGS.showAvatars); + }); + + it("can still be changed afterwards, being a starting point and not a rule", () => { + resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} }); + useSettings.getState().seedFromPolicy(); + useSettings.getState().update({ conversationMode: true }); + expect(useSettings.getState().settings.conversationMode).toBe(true); + }); + + it("does nothing at all when the installation has set none", () => { + const before = useSettings.getState().settings; + useSettings.getState().seedFromPolicy(); + expect(useSettings.getState().settings).toBe(before); + }); +}); + +describe("enforced settings, which the reader may not change", () => { + beforeEach(() => { + resetSettingsPolicyForTest({ defaults: {}, enforced: { conversationMode: true } as never }); + }); + + it("survives an update that tries to change it", () => { + useSettings.getState().update({ conversationMode: false }); + expect(useSettings.getState().settings.conversationMode).toBe(true); + }); + + it("does not stop the rest of that same update", () => { + // The one key is refused; the others are the reader's business. + useSettings.getState().update({ conversationMode: false, showAvatars: false }); + expect(useSettings.getState().settings.conversationMode).toBe(true); + expect(useSettings.getState().settings.showAvatars).toBe(false); + }); + + it("survives a settings file arriving from another device", () => { + // An older sign-in wrote past the policy before it existed. Hydrating must + // not put that back. + useSettings.getState().hydrate({ conversationMode: false, showAvatars: false }); + expect(useSettings.getState().settings.conversationMode).toBe(true); + expect(useSettings.getState().settings.showAvatars).toBe(false); + }); + + it("survives a reset", () => { + // Resetting must not be the way around a policy. + useSettings.getState().reset(); + expect(useSettings.getState().settings.conversationMode).toBe(true); + }); + + it("survives an imported settings file", () => { + useSettings.getState().importJson(JSON.stringify({ conversationMode: false })); + expect(useSettings.getState().settings.conversationMode).toBe(true); + }); +}); + +describe("reset, where the installation has chosen defaults", () => { + it("goes back to the installation's answer rather than to ihasmail's", () => { + resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} }); + useSettings.getState().update({ conversationMode: true }); + useSettings.getState().reset(); + expect(useSettings.getState().settings.conversationMode).toBe(false); + }); +}); diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index c94a699..014273c 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -4,6 +4,7 @@ import { hasCachedJson, loadJson, saveJson } from "@/lib/storage"; import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette"; import type { SortLevel, SortPreset } from "@/lib/listSort"; import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync"; +import { policyDefaults, policyEnforced } from "@/lib/settingsPolicy"; import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime"; import type { SwipeAction } from "@/lib/swipe"; import { resolveUiLanguage } from "@/lib/languages"; @@ -402,6 +403,14 @@ interface SettingsState { importJson(json: string): boolean; /** Apply the account's settings file over the cached ones. */ hydrate(remote: Record): void; + /** + * Seed an account that has never had settings of its own. + * + * Only for that case, which is why it is not `update`: these are a starting + * point the reader may change, so applying them to somebody who already has + * settings would be overwriting choices rather than defaulting them. + */ + seedFromPolicy(): void; } const initialSettings = loadJson("settings", DEFAULT_SETTINGS); @@ -428,7 +437,14 @@ export const useSettings = create((set, get) => ({ * change the theme cannot forget to update it and strand an older device * on a theme nobody picked. */ - const merged = { ...get().settings, ...patch }; + /* + * Enforcement lives here rather than only on the controls. The controls are + * disabled and say why, which is the part a reader sees -- but a setting + * the installation has decided must not be changeable through an imported + * settings file, a keyboard shortcut, or a control somebody adds later and + * forgets to check. There is one door, so the lock is on it. Issue #207. + */ + const merged = { ...get().settings, ...patch, ...policyEnforced() }; const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches); const settings = { ...merged, theme: legacyTheme({ palette: merged.palette, mode: merged.mode }, prefersDark) }; saveJson("settings", settings); @@ -442,13 +458,22 @@ export const useSettings = create((set, get) => ({ queueSettingsPush(syncedPart(settings)); } }, + seedFromPolicy() { + const defaults = policyDefaults(); + if (!Object.keys(defaults).length) return; + get().update(defaults); + }, reset() { - saveJson("settings", DEFAULT_SETTINGS); - set({ settings: DEFAULT_SETTINGS }); - applyTheme(DEFAULT_SETTINGS); - applyDateTimePrefs(DEFAULT_SETTINGS); - applyLang(DEFAULT_SETTINGS); - queueSettingsPush(syncedPart(DEFAULT_SETTINGS)); + /* Back to how this installation starts an account, not to how ihasmail + starts one: resetting must not be a way around a policy, and the defaults + an admin chose are the honest meaning of "reset" where there are any. */ + const base = { ...DEFAULT_SETTINGS, ...policyDefaults(), ...policyEnforced() }; + saveJson("settings", base); + set({ settings: base }); + applyTheme(base); + applyDateTimePrefs(base); + applyLang(base); + queueSettingsPush(syncedPart(base)); }, exportJson() { return JSON.stringify(get().settings, null, 2); @@ -463,7 +488,10 @@ export const useSettings = create((set, get) => ({ } }, hydrate(remote) { - const settings = mergeRemote(get().settings, remote, pendingSettingsKeys()); + /* Enforced values win over what the account's own file says: a policy that + an older sign-in has already written past would otherwise stay written + past for ever. */ + const settings = { ...mergeRemote(get().settings, remote, pendingSettingsKeys()), ...policyEnforced() }; // Cache it, so the next first frame on this browser is already right. saveJson("settings", settings); set({ settings }); diff --git a/web/src/ui/misc.tsx b/web/src/ui/misc.tsx index 17c3c0d..46c68a7 100644 --- a/web/src/ui/misc.tsx +++ b/web/src/ui/misc.tsx @@ -20,9 +20,9 @@ export function Avatar({ who, size, className }: { who: EmailAddress | { name?: ); } -export function Switch({ checked, onChange, label, hint, disabled }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode; hint?: ReactNode; disabled?: boolean }) { +export function Switch({ checked, onChange, label, hint, disabled, locked }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode; hint?: ReactNode; disabled?: boolean; locked?: boolean }) { const sw = ( - diff --git a/web/src/views/settings/PrivacySettings.tsx b/web/src/views/settings/PrivacySettings.tsx index 40789e5..bef74c3 100644 --- a/web/src/views/settings/PrivacySettings.tsx +++ b/web/src/views/settings/PrivacySettings.tsx @@ -5,6 +5,7 @@ import { domainOf } from "@/lib/address"; import { Switch } from "@/ui/misc"; import { X } from "lucide-react"; import { t } from "@/lib/i18n"; +import { isEnforced } from "@/lib/settingsPolicy"; /** * Everything about what reaches a sender, and what asks before it happens. @@ -36,7 +37,7 @@ export function PrivacySettings() {

{t("Remote content")}

- update({ imagePolicy: e.target.value as typeof s.imagePolicy })}> @@ -67,10 +68,10 @@ export function PrivacySettings() { )}

{t("Read receipts")}

- update({ requestReadReceipt: v })} label={t("Always request read receipts")} /> + update({ requestReadReceipt: v })} label={t("Always request read receipts")} />
- update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}> @@ -108,7 +109,7 @@ export function PrivacySettings() {
- update({ replyAllThreshold: Number(e.target.value) })}> @@ -136,7 +137,7 @@ export function PrivacySettings() {

{t("Before it happens")}

- update({ undoSendSeconds: Number(e.target.value) })}> @@ -145,8 +146,8 @@ export function PrivacySettings() {

{t("The message is held in this browser and has not been submitted yet, so taking it back costs nothing.")}

- update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} /> - update({ confirmDelete: v })} label={t("Confirm before deleting")} /> + update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} /> + update({ confirmDelete: v })} label={t("Confirm before deleting")} />
); }