Merge pull request #231 from Coffey-Labs/feat/policy-change-once
Apply installation policy changes once each, per account
This commit is contained in:
@@ -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 }
|
||||
"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
|
||||
|
||||
|
||||
+31
-1
@@ -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<string, unknown>; enforced: Record<string, unknown> } {
|
||||
function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Record<string, unknown>; changes: Array<{ version: string; settings: Record<string, unknown> }> } {
|
||||
const parse = (raw: string, where: string): Record<string, unknown> => {
|
||||
try {
|
||||
const v = JSON.parse(raw) as unknown;
|
||||
@@ -142,6 +146,30 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; 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<string, unknown> }> => {
|
||||
if (v === undefined) return [];
|
||||
if (!Array.isArray(v)) throw new Error(`Invalid ${where}: "changes" must be a list`);
|
||||
const seen = new Set<string>();
|
||||
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<string, unknown> };
|
||||
});
|
||||
};
|
||||
|
||||
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<string, unknown>; enforced: Re
|
||||
return {
|
||||
defaults: (whole.defaults as Record<string, unknown>) ?? {},
|
||||
enforced: (whole.enforced as Record<string, unknown>) ?? {},
|
||||
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") : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -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();
|
||||
|
||||
@@ -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<Settings>;
|
||||
}
|
||||
|
||||
export interface SettingsPolicy {
|
||||
defaults: Partial<Settings>;
|
||||
enforced: Partial<Settings>;
|
||||
/**
|
||||
* 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<SettingsPolicy> | null = null;
|
||||
@@ -45,10 +59,18 @@ export async function loadSettingsPolicy(): Promise<SettingsPolicy> {
|
||||
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<string, unknown>; enforced?: Record<string, unknown> } };
|
||||
const body = (await res.json()) as {
|
||||
settingsPolicy?: { defaults?: Record<string, unknown>; enforced?: Record<string, unknown>; changes?: Array<{ version: string; settings: Record<string, unknown> }> };
|
||||
};
|
||||
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<string, unknown>), enforced: known(next.enforced as Record<string, unknown>) };
|
||||
export function resetSettingsPolicyForTest(next: Partial<SettingsPolicy> = {}): void {
|
||||
policy = {
|
||||
defaults: known((next.defaults ?? {}) as Record<string, unknown>),
|
||||
enforced: known((next.enforced ?? {}) as Record<string, unknown>),
|
||||
changes: (next.changes ?? [])
|
||||
.map((c) => ({ version: c.version, settings: known(c.settings as Record<string, unknown>) }))
|
||||
.filter((c) => c.version && Object.keys(c.settings).length),
|
||||
};
|
||||
fetched = Promise.resolve(policy);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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} 件の予定をエクスポートしました" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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} события" },
|
||||
|
||||
@@ -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} події" },
|
||||
|
||||
@@ -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} 个日程" },
|
||||
|
||||
@@ -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<string, unknown>) => ({ 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>("settings", DEFAULT_SETTINGS);
|
||||
@@ -463,6 +490,31 @@ export const useSettings = create<SettingsState>((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<Settings> = {};
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user