diff --git a/README.md b/README.md index f5f6c68..7618949 100644 --- a/README.md +++ b/README.md @@ -128,34 +128,106 @@ thousand pupils is not a plan. -e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}' ``` -Or, where mounting a file is easier than quoting JSON in a unit file: +Three powers, and the differences between them matter: + +| Section | Applies to | Reader can change it | +| --- | --- | --- | +| `defaults` | accounts that have never had settings of their own | yes, at any time | +| `enforced` | everyone, on every load | no — the control goes dead | +| `changes` | everyone, **once each**, including existing accounts | yes, afterwards, and it stays changed | + +`changes` is the one that needs explaining. It turns something on for people who +are *already here* — the reason a plain default is not enough — while still +leaving them the last word. Each entry carries its own `version`, which every +account remembers once it has had it, so the change is applied exactly once per +person and a reader who turns it back off keeps it off. It is a schema migration +in shape, and that is deliberately whose idea it was ([#207]). + +Nothing is configured by default: an installation that sets none of these +behaves exactly as ihasmail always has. + +### Passing a policy to Docker + +Where a file is easier to manage than JSON quoted in a unit file — and it +usually is once there are `changes` in it — mount one and name it: ```bash --e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json +docker run -d --name ihasmail \ + -e STALWART_URL=https://mail.example.org \ + -e APP_SECRET="$(openssl rand -hex 32)" \ + -e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json \ + -v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro \ + -p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest ``` ```json { - "defaults": { "externalSenderBanner": true }, - "enforced": { "externalRecipientConfirm": true } + "defaults": { "externalSenderBanner": true }, + "enforced": { "externalRecipientConfirm": true }, + "changes": [ + { "version": "20260902084513", "settings": { "externalSenderBanner": true } }, + { "version": "20261014091500", "settings": { "externalLinkWarning": 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. +Mount it read-only: the server only ever reads it, and `:ro` keeps that true +under `--read-only` as well. -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. +Or without a file at all, which is what an immutable deployment with no volume +wants: + +```bash +docker run -d --name ihasmail --read-only --tmpfs /tmp \ + -e IMMUTABLE=1 -e SESSION_FILE= \ + -e STALWART_URL=https://mail.example.org \ + -e APP_SECRET="$(openssl rand -hex 32)" \ + -e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \ + -e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}' \ + -e SETTINGS_CHANGES='[{"version":"20260902084513","settings":{"externalSenderBanner":true}}]' \ + -p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest +``` + +In `docker-compose.yml`: + +```yaml +services: + ihasmail: + image: ghcr.io/coffey-labs/ihasmail:latest + environment: + SETTINGS_POLICY_FILE: /etc/ihasmail/policy.json + volumes: + - ./policy.json:/etc/ihasmail/policy.json:ro +``` + +A policy is read once at startup, so **editing it means restarting the +container**. There is no reload signal, deliberately: an installation-wide +setting changing under a running instance would be harder to reason about than +one that changes when you say so. + +### Writing a policy + +Both sections take the same names and values a settings export uses, so +`Settings → General → Export` on one account you have configured by hand is the +quickest way to write one — copy the keys you care about out of the file. + +Three checks worth knowing about, because they fail loudly rather than quietly: + +- **Malformed JSON stops the server at startup.** A policy that silently did not + apply is indistinguishable from the feature not working. +- **Every change needs a unique `version`.** Two changes sharing one, or a change + with no `version` or no `settings`, is a startup error. +- **Keys this build does not have are dropped**, the same rule an imported + settings file gets. A `changes` entry whose keys are *all* unknown is dropped + whole rather than recorded as applied, so it still runs on an ihasmail that + does have the setting. 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. +so an imported settings file, a settings file synced from a device that predates +the policy, and "reset to defaults" cannot get around it. Reset returns to your +defaults, not to ihasmail's. + +[#207]: https://github.com/Coffey-Labs/ihasmail/issues/207 ## Architecture diff --git a/server/src/config.ts b/server/src/config.ts index 21e8eab..7efa850 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -124,12 +124,16 @@ if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", impor * - `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. + * - `changes` are applied once each, to everybody, including accounts that + * already exist -- and can be changed back afterwards. Each carries its own + * `version`, which is how an account remembers the ones it has had. The + * reporter's own analogy is a schema migration and this is that shape. * * 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 } { +function readSettingsPolicy(): { defaults: Record; enforced: Record; changes: Array<{ version: string; settings: Record }> } { const parse = (raw: string, where: string): Record => { try { const v = JSON.parse(raw) as unknown; @@ -142,6 +146,30 @@ function readSettingsPolicy(): { defaults: Record; enforced: Re } }; + /** + * A change list, checked rather than trusted. + * + * Every entry needs a `version` that is unique within the file: it is what an + * account stores to say it has had this one, so a duplicate would make two + * changes indistinguishable and a missing one would apply for ever. + */ + const parseChanges = (v: unknown, where: string): Array<{ version: string; settings: Record }> => { + if (v === undefined) return []; + if (!Array.isArray(v)) throw new Error(`Invalid ${where}: "changes" must be a list`); + const seen = new Set(); + return v.map((entry, i) => { + const e = entry as { version?: unknown; settings?: unknown }; + const version = typeof e.version === "string" ? e.version.trim() : ""; + if (!version) throw new Error(`Invalid ${where}: changes[${i}] has no "version"`); + if (seen.has(version)) throw new Error(`Invalid ${where}: two changes share the version "${version}"`); + seen.add(version); + if (!e.settings || typeof e.settings !== "object" || Array.isArray(e.settings)) { + throw new Error(`Invalid ${where}: changes[${i}] ("${version}") has no "settings" object`); + } + return { version, settings: e.settings as Record }; + }); + }; + const file = process.env.SETTINGS_POLICY_FILE; if (file) { if (!existsSync(file)) throw new Error(`SETTINGS_POLICY_FILE does not exist: ${file}`); @@ -149,11 +177,13 @@ function readSettingsPolicy(): { defaults: Record; enforced: Re return { defaults: (whole.defaults as Record) ?? {}, enforced: (whole.enforced as Record) ?? {}, + changes: parseChanges(whole.changes, `SETTINGS_POLICY_FILE (${file})`), }; } 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") : {}, + changes: process.env.SETTINGS_CHANGES ? parseChanges(JSON.parse(process.env.SETTINGS_CHANGES), "SETTINGS_CHANGES") : [], }; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 0532444..d3fd548 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,7 +9,7 @@ import { useFiles } from "@/store/files"; import { useSieve } from "@/store/sieve"; import { push } from "@/jmap/push"; import { client } from "@/jmap/client"; -import { ToastHost } from "@/ui/toast"; +import { ToastHost, toast } from "@/ui/toast"; import { ConfirmHost } from "@/ui/dialog"; import { Spinner } from "@/ui/misc"; import { LoginPage } from "@/views/Login"; @@ -21,9 +21,9 @@ 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 { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n"; import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges"; -import { BASE_PATH } from "@/lib/basePath"; +import { BASE_PATH, withBase } from "@/lib/basePath"; const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView }))); const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView }))); @@ -157,6 +157,19 @@ function AuthedApp() { // the installation's defaults are what it starts on rather than // ihasmail's. Issue #207. else useSettings.getState().seedFromPolicy(); + /* + * After both, and for everybody: a change the installation wants applied + * once has to reach accounts that already exist, which is the whole of + * why it is not just a default. Each is remembered, so a reader who turns + * one back off keeps it off. Issue #207. + */ + const applied = useSettings.getState().applyPolicyChanges(); + if (applied.length) { + toast.show(plural(applied.length, { + one: "Your administrator changed {n} setting", + other: "Your administrator changed {n} settings", + }), { action: { label: t("Settings"), onClick: () => { window.location.href = withBase("/settings/general"); } } }); + } // 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 index 563e37d..815bcfa 100644 --- a/web/src/lib/settingsPolicy.ts +++ b/web/src/lib/settingsPolicy.ts @@ -14,12 +14,26 @@ import { DEFAULT_SETTINGS, type Settings } from "@/store/settings"; * sign-in page, so this costs nothing on a cold load and is available before * anybody's settings are read. */ +export interface PolicyChange { + /** Unique in the policy; what an account stores to say it has had this one. */ + version: string; + settings: Partial; +} + export interface SettingsPolicy { defaults: Partial; enforced: Partial; + /** + * Applied once each, to everybody, and changeable afterwards. + * + * The third power in #207, and the one that needed somewhere to remember: an + * admin turning something on for existing accounts, without it snapping back + * on for a reader who then turned it off. + */ + changes: PolicyChange[]; } -const EMPTY: SettingsPolicy = { defaults: {}, enforced: {} }; +const EMPTY: SettingsPolicy = { defaults: {}, enforced: {}, changes: [] }; let policy: SettingsPolicy = EMPTY; let fetched: Promise | null = null; @@ -45,10 +59,18 @@ export async function loadSettingsPolicy(): Promise { 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 } }; + const body = (await res.json()) as { + settingsPolicy?: { defaults?: Record; enforced?: Record; changes?: Array<{ version: string; settings: Record }> }; + }; policy = { defaults: known(body.settingsPolicy?.defaults ?? {}), enforced: known(body.settingsPolicy?.enforced ?? {}), + /* A change whose every key this build does not have is dropped whole: + applying nothing and then recording it as applied would mean it never + ran on the ihasmail that does have the setting. */ + changes: (body.settingsPolicy?.changes ?? []) + .map((c) => ({ version: c.version, settings: known(c.settings ?? {}) })) + .filter((c) => c.version && Object.keys(c.settings).length), }; return policy; } catch { @@ -75,8 +97,19 @@ export function isEnforced(key: keyof Settings): boolean { return key in policy.enforced; } +/** Changes the installation wants applied once each. */ +export function policyChanges(): PolicyChange[] { + return policy.changes; +} + /** 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) }; +export function resetSettingsPolicyForTest(next: Partial = {}): void { + policy = { + defaults: known((next.defaults ?? {}) as Record), + enforced: known((next.enforced ?? {}) as Record), + changes: (next.changes ?? []) + .map((c) => ({ version: c.version, settings: known(c.settings as Record) })) + .filter((c) => c.version && Object.keys(c.settings).length), + }; fetched = Promise.resolve(policy); } diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 5045616..b40e64e 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1079,6 +1079,7 @@ export const catalog: Catalog = { "Nothing unread here": "Hier ist nichts ungelesen", }, plurals: { + "Your administrator changed {n} settings": { one: "Ihre Administration hat {n} Einstellung geändert", other: "Ihre Administration hat {n} Einstellungen geändert" }, "Already here: {n} contacts, nothing imported": { one: "Bereits vorhanden: {n} Kontakt, nichts importiert", other: "Bereits vorhanden: {n} Kontakte, nichts importiert" }, "All {n} are already in your contacts": { one: "Bereits in Ihren Kontakten", other: "Alle {n} sind bereits in Ihren Kontakten" }, "Exported {n} events": { one: "{n} Termin exportiert", other: "{n} Termine exportiert" }, diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index f42c76d..91b2831 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1052,6 +1052,7 @@ export const catalog: Catalog = { "Nothing unread here": "Aquí no hay nada sin leer", }, plurals: { + "Your administrator changed {n} settings": { one: "Tu administración cambió {n} ajuste", other: "Tu administración cambió {n} ajustes" }, "Already here: {n} contacts, nothing imported": { one: "Ya estaba aquí: {n} contacto, no se importó nada", other: "Ya estaban aquí: {n} contactos, no se importó nada" }, "All {n} are already in your contacts": { one: "Ya está en tus contactos", other: "Los {n} ya están en tus contactos" }, "Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" }, diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 89c22e0..a621da1 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1057,6 +1057,7 @@ export const catalog: Catalog = { "Nothing unread here": "Rien de non lu ici", }, plurals: { + "Your administrator changed {n} settings": { one: "Votre administration a modifié {n} paramètre", other: "Votre administration a modifié {n} paramètres" }, "Already here: {n} contacts, nothing imported": { one: "Déjà présent : {n} contact, rien d’importé", other: "Déjà présents : {n} contacts, rien d’importé" }, "All {n} are already in your contacts": { one: "Déjà dans vos contacts", other: "Les {n} sont déjà dans vos contacts" }, "Exported {n} events": { one: "{n} événement exporté", other: "{n} événements exportés" }, diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index cc0ec07..0276882 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1060,6 +1060,7 @@ export const catalog: Catalog = { "Nothing unread here": "ここに未読はありません", }, plurals: { + "Your administrator changed {n} settings": { other: "管理者が {n} 件の設定を変更しました" }, "Already here: {n} contacts, nothing imported": { other: "すでに存在: {n} 件、インポートなし" }, "All {n} are already in your contacts": { other: "{n} 件はすでに連絡先にあります" }, "Exported {n} events": { other: "{n} 件の予定をエクスポートしました" }, diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index c76055b..68e6bd3 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1048,6 +1048,7 @@ export const catalog: Catalog = { "Nothing unread here": "Hier is niets ongelezen", }, plurals: { + "Your administrator changed {n} settings": { one: "Uw beheerder heeft {n} instelling gewijzigd", other: "Uw beheerder heeft {n} instellingen gewijzigd" }, "Already here: {n} contacts, nothing imported": { one: "Al aanwezig: {n} contact, niets geïmporteerd", other: "Al aanwezig: {n} contacten, niets geïmporteerd" }, "All {n} are already in your contacts": { one: "Staat al in uw contacten", other: "Alle {n} staan al in uw contacten" }, "Exported {n} events": { one: "{n} afspraak geëxporteerd", other: "{n} afspraken geëxporteerd" }, diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index eeb7ad3..9260146 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1055,6 +1055,7 @@ export const catalog: Catalog = { "Nothing unread here": "Não há nada não lido aqui", }, plurals: { + "Your administrator changed {n} settings": { one: "Sua administração alterou {n} configuração", other: "Sua administração alterou {n} configurações" }, "Already here: {n} contacts, nothing imported": { one: "Já estava aqui: {n} contato, nada importado", other: "Já estavam aqui: {n} contatos, nada importado" }, "All {n} are already in your contacts": { one: "Já está nos seus contatos", other: "Todos os {n} já estão nos seus contatos" }, "Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" }, diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 4ca7c72..e5ce14c 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1054,6 +1054,7 @@ export const catalog: Catalog = { "Nothing unread here": "Здесь нет непрочитанного", }, plurals: { + "Your administrator changed {n} settings": { one: "Администратор изменил {n} настройку", few: "Администратор изменил {n} настройки", many: "Администратор изменил {n} настроек", other: "Администратор изменил {n} настройки" }, "Already here: {n} contacts, nothing imported": { one: "Уже есть: {n} контакт, ничего не импортировано", few: "Уже есть: {n} контакта, ничего не импортировано", many: "Уже есть: {n} контактов, ничего не импортировано", other: "Уже есть: {n} контакта, ничего не импортировано" }, "All {n} are already in your contacts": { one: "Уже в ваших контактах", few: "Все {n} уже в ваших контактах", many: "Все {n} уже в ваших контактах", other: "Все {n} уже в ваших контактах" }, "Exported {n} events": { one: "Экспортировано {n} событие", few: "Экспортировано {n} события", many: "Экспортировано {n} событий", other: "Экспортировано {n} события" }, diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 8e43073..2df98b3 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1048,6 +1048,7 @@ export const catalog: Catalog = { "Nothing unread here": "Тут немає непрочитаного", }, plurals: { + "Your administrator changed {n} settings": { one: "Адміністратор змінив {n} налаштування", few: "Адміністратор змінив {n} налаштування", many: "Адміністратор змінив {n} налаштувань", other: "Адміністратор змінив {n} налаштування" }, "Already here: {n} contacts, nothing imported": { one: "Уже є: {n} контакт, нічого не імпортовано", few: "Уже є: {n} контакти, нічого не імпортовано", many: "Уже є: {n} контактів, нічого не імпортовано", other: "Уже є: {n} контакти, нічого не імпортовано" }, "All {n} are already in your contacts": { one: "Уже у ваших контактах", few: "Усі {n} уже у ваших контактах", many: "Усі {n} уже у ваших контактах", other: "Усі {n} уже у ваших контактах" }, "Exported {n} events": { one: "Експортовано {n} подію", few: "Експортовано {n} події", many: "Експортовано {n} подій", other: "Експортовано {n} події" }, diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 972420b..330a28d 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1059,6 +1059,7 @@ export const catalog: Catalog = { "Nothing unread here": "这里没有未读邮件", }, plurals: { + "Your administrator changed {n} settings": { other: "管理员更改了 {n} 项设置" }, "Already here: {n} contacts, nothing imported": { other: "已存在 {n} 个,未导入" }, "All {n} are already in your contacts": { other: "这 {n} 个已在您的联系人中" }, "Exported {n} events": { other: "已导出 {n} 个日程" }, diff --git a/web/src/store/__tests__/settings-policy.test.ts b/web/src/store/__tests__/settings-policy.test.ts index e833826..140028d 100644 --- a/web/src/store/__tests__/settings-policy.test.ts +++ b/web/src/store/__tests__/settings-policy.test.ts @@ -122,3 +122,96 @@ describe("reset, where the installation has chosen defaults", () => { expect(useSettings.getState().settings.conversationMode).toBe(false); }); }); + +/* + * The third power: applied once each, to everybody, and changeable afterwards. + * + * The difference from `enforced` is entirely in the remembering. Both reach an + * account that already exists; only this one lets the reader have the last + * word, and only because the version is stored. + */ +describe("changes an installation wants applied once", () => { + const change = (version: string, settings: Record) => ({ version, settings } as never); + + it("applies one the account has not had", () => { + resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: false })] }); + const applied = useSettings.getState().applyPolicyChanges(); + expect(applied.map((c) => c.version)).toEqual(["20260902"]); + expect(useSettings.getState().settings.conversationMode).toBe(false); + }); + + it("remembers it, so the next sign-in does not do it again", () => { + resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: false })] }); + useSettings.getState().applyPolicyChanges(); + // The reader decides otherwise, which is the whole difference from enforcing. + useSettings.getState().update({ conversationMode: true }); + expect(useSettings.getState().applyPolicyChanges()).toEqual([]); + expect(useSettings.getState().settings.conversationMode).toBe(true); + }); + + it("reaches an account that had already chosen otherwise", () => { + /* + * Confirmed as intended on #207: the point is to reach everybody who is + * already here, so somebody who turned it off last week does get it turned + * back on -- once. + */ + useSettings.getState().update({ conversationMode: false }); + resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: true })] }); + useSettings.getState().applyPolicyChanges(); + expect(useSettings.getState().settings.conversationMode).toBe(true); + }); + + it("applies only the ones that are new, keeping what it has seen", () => { + resetSettingsPolicyForTest({ changes: [change("A", { conversationMode: false })] }); + useSettings.getState().applyPolicyChanges(); + resetSettingsPolicyForTest({ + changes: [change("A", { conversationMode: false }), change("B", { showAvatars: false })], + }); + const applied = useSettings.getState().applyPolicyChanges(); + expect(applied.map((c) => c.version)).toEqual(["B"]); + expect(useSettings.getState().settings.appliedPolicyChanges).toEqual(["A", "B"]); + }); + + it("does not skip a change dated earlier than one already applied", () => { + // Ids, not a high-water mark. An admin backfilling a change must not find + // it silently ignored because a later one went first. + resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: false })] }); + useSettings.getState().applyPolicyChanges(); + resetSettingsPolicyForTest({ + changes: [change("20260101", { showAvatars: false }), change("20260902", { conversationMode: false })], + }); + expect(useSettings.getState().applyPolicyChanges().map((c) => c.version)).toEqual(["20260101"]); + expect(useSettings.getState().settings.showAvatars).toBe(false); + }); + + it("goes out as one write however many changes are pending", () => { + resetSettingsPolicyForTest({ + changes: [change("A", { conversationMode: false }), change("B", { showAvatars: false })], + }); + const applied = useSettings.getState().applyPolicyChanges(); + expect(applied).toHaveLength(2); + expect(useSettings.getState().settings.conversationMode).toBe(false); + expect(useSettings.getState().settings.showAvatars).toBe(false); + }); + + it("does nothing, and says so, when there are none", () => { + expect(useSettings.getState().applyPolicyChanges()).toEqual([]); + }); + + it("cannot undo an enforced setting, which outranks it", () => { + resetSettingsPolicyForTest({ + enforced: { conversationMode: true } as never, + changes: [change("A", { conversationMode: false })], + }); + useSettings.getState().applyPolicyChanges(); + expect(useSettings.getState().settings.conversationMode).toBe(true); + }); + + it("drops a change whose settings this build does not have, rather than recording it", () => { + // Recording it as applied would mean it never runs on the ihasmail that + // does have the setting. + resetSettingsPolicyForTest({ changes: [change("A", { notARealSetting: true })] }); + expect(useSettings.getState().applyPolicyChanges()).toEqual([]); + expect(useSettings.getState().settings.appliedPolicyChanges).toEqual([]); + }); +}); diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 014273c..1f1d94f 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -4,7 +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 { policyChanges, policyDefaults, policyEnforced, type PolicyChange } from "@/lib/settingsPolicy"; import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime"; import type { SwipeAction } from "@/lib/swipe"; import { resolveUiLanguage } from "@/lib/languages"; @@ -228,6 +228,24 @@ export interface Settings { * id belonging to another account simply never matches. */ hiddenIdentities: string[]; + /** + * Installation policy changes this account has already had applied. + * + * The third power in #207: an admin turns a setting on for everybody who is + * already here, and readers may still turn it back off afterwards. That only + * works if "already applied" is remembered, or the next sign-in would undo + * their decision again and the setting would be enforcement wearing a + * different hat. + * + * Ids, not a high-water mark. The reporter's analogy is a schema migration, + * where each change carries its own version, and remembering the set rather + * than the maximum is what lets an admin add a change dated earlier than one + * already applied without it being silently skipped. + * + * Synced with the rest, so it is per account and not per browser: signing in + * on a phone must not apply everything a second time. + */ + appliedPolicyChanges: string[]; } export const DEFAULT_SETTINGS: Settings = { @@ -314,6 +332,7 @@ export const DEFAULT_SETTINGS: Settings = { ], defaultIdentityByAccount: {}, hiddenIdentities: [], + appliedPolicyChanges: [], }; /** @@ -411,6 +430,14 @@ interface SettingsState { * settings would be overwriting choices rather than defaulting them. */ seedFromPolicy(): void; + /** + * Apply the installation's change list, each entry once. + * + * Returns the changes that were applied, so the caller can say what moved -- + * a setting changing under somebody without a word is the part of this the + * reporter was uneasy about, and rightly. + */ + applyPolicyChanges(): PolicyChange[]; } const initialSettings = loadJson("settings", DEFAULT_SETTINGS); @@ -463,6 +490,31 @@ export const useSettings = create((set, get) => ({ if (!Object.keys(defaults).length) return; get().update(defaults); }, + /* + * The third power in #207, and the only one that remembers anything. + * + * A change is applied when this account has not already had it, whatever the + * setting currently says: the point is to reach everybody who is already + * here, so somebody who had turned it off before the admin decided does get + * it turned back on. That is intended and the reporter has confirmed it -- + * the difference from `enforced` is that they may turn it off again + * afterwards and it will stay off, because the version is remembered. + * + * Ids rather than a high-water mark, so a change dated earlier than one + * already applied is not silently skipped. + * + * One `update` for the lot, not one per change: each would push a settings + * file, and a policy with four changes on a first sign-in would write four. + */ + applyPolicyChanges() { + const seen = new Set(get().settings.appliedPolicyChanges ?? []); + const pending = policyChanges().filter((c) => !seen.has(c.version)); + if (!pending.length) return []; + let patch: Partial = {}; + for (const c of pending) patch = { ...patch, ...c.settings }; + get().update({ ...patch, appliedPolicyChanges: [...seen, ...pending.map((c) => c.version)] }); + return pending; + }, reset() { /* 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