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}'
|
-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
|
```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
|
```json
|
||||||
{
|
{
|
||||||
"defaults": { "externalSenderBanner": true },
|
"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
|
Mount it read-only: the server only ever reads it, and `:ro` keeps that true
|
||||||
settings of its own; the reader can change any of them afterwards, and they are
|
under `--read-only` as well.
|
||||||
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
|
Or without a file at all, which is what an immutable deployment with no volume
|
||||||
`Settings → General → Export` on a configured account is the quickest way to
|
wants:
|
||||||
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.
|
```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,
|
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
|
so an imported settings file, a settings file synced from a device that predates
|
||||||
predates the policy cannot get around it.
|
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
|
## 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
|
* - `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
|
* controls stay visible and go dead, which the issue asked for by name: a
|
||||||
* missing control confuses somebody who has used ihasmail elsewhere.
|
* 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
|
* 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
|
* production runs read-only with no volume -- an installation that cannot mount
|
||||||
* a file can still set a variable.
|
* 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> => {
|
const parse = (raw: string, where: string): Record<string, unknown> => {
|
||||||
try {
|
try {
|
||||||
const v = JSON.parse(raw) as unknown;
|
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;
|
const file = process.env.SETTINGS_POLICY_FILE;
|
||||||
if (file) {
|
if (file) {
|
||||||
if (!existsSync(file)) throw new Error(`SETTINGS_POLICY_FILE does not exist: ${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 {
|
return {
|
||||||
defaults: (whole.defaults as Record<string, unknown>) ?? {},
|
defaults: (whole.defaults as Record<string, unknown>) ?? {},
|
||||||
enforced: (whole.enforced as Record<string, unknown>) ?? {},
|
enforced: (whole.enforced as Record<string, unknown>) ?? {},
|
||||||
|
changes: parseChanges(whole.changes, `SETTINGS_POLICY_FILE (${file})`),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
defaults: process.env.SETTINGS_DEFAULTS ? parse(process.env.SETTINGS_DEFAULTS, "SETTINGS_DEFAULTS") : {},
|
defaults: process.env.SETTINGS_DEFAULTS ? parse(process.env.SETTINGS_DEFAULTS, "SETTINGS_DEFAULTS") : {},
|
||||||
enforced: process.env.SETTINGS_ENFORCED ? parse(process.env.SETTINGS_ENFORCED, "SETTINGS_ENFORCED") : {},
|
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 { useSieve } from "@/store/sieve";
|
||||||
import { push } from "@/jmap/push";
|
import { push } from "@/jmap/push";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { ToastHost } from "@/ui/toast";
|
import { ToastHost, toast } from "@/ui/toast";
|
||||||
import { ConfirmHost } from "@/ui/dialog";
|
import { ConfirmHost } from "@/ui/dialog";
|
||||||
import { Spinner } from "@/ui/misc";
|
import { Spinner } from "@/ui/misc";
|
||||||
import { LoginPage } from "@/views/Login";
|
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 { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||||
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
|
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 { 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 ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
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
|
// the installation's defaults are what it starts on rather than
|
||||||
// ihasmail's. Issue #207.
|
// ihasmail's. Issue #207.
|
||||||
else useSettings.getState().seedFromPolicy();
|
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
|
// The catalogue for whatever language that turned out to be. Hydrating
|
||||||
// asks for it; this is waiting for the answer.
|
// asks for it; this is waiting for the answer.
|
||||||
await whenLanguageReady();
|
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
|
* sign-in page, so this costs nothing on a cold load and is available before
|
||||||
* anybody's settings are read.
|
* 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 {
|
export interface SettingsPolicy {
|
||||||
defaults: Partial<Settings>;
|
defaults: Partial<Settings>;
|
||||||
enforced: 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 policy: SettingsPolicy = EMPTY;
|
||||||
let fetched: Promise<SettingsPolicy> | null = null;
|
let fetched: Promise<SettingsPolicy> | null = null;
|
||||||
@@ -45,10 +59,18 @@ export async function loadSettingsPolicy(): Promise<SettingsPolicy> {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(withBase("/api/config"), { credentials: "same-origin" });
|
const res = await fetch(withBase("/api/config"), { credentials: "same-origin" });
|
||||||
if (!res.ok) return EMPTY;
|
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 = {
|
policy = {
|
||||||
defaults: known(body.settingsPolicy?.defaults ?? {}),
|
defaults: known(body.settingsPolicy?.defaults ?? {}),
|
||||||
enforced: known(body.settingsPolicy?.enforced ?? {}),
|
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;
|
return policy;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -75,8 +97,19 @@ export function isEnforced(key: keyof Settings): boolean {
|
|||||||
return key in policy.enforced;
|
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. */
|
/** Only for tests: forget what was fetched. */
|
||||||
export function resetSettingsPolicyForTest(next: SettingsPolicy = EMPTY): void {
|
export function resetSettingsPolicyForTest(next: Partial<SettingsPolicy> = {}): void {
|
||||||
policy = { defaults: known(next.defaults as Record<string, unknown>), enforced: known(next.enforced as Record<string, unknown>) };
|
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);
|
fetched = Promise.resolve(policy);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1079,6 +1079,7 @@ export const catalog: Catalog = {
|
|||||||
"Nothing unread here": "Hier ist nichts ungelesen",
|
"Nothing unread here": "Hier ist nichts ungelesen",
|
||||||
},
|
},
|
||||||
plurals: {
|
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" },
|
"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" },
|
"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" },
|
"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",
|
"Nothing unread here": "Aquí no hay nada sin leer",
|
||||||
},
|
},
|
||||||
plurals: {
|
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" },
|
"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" },
|
"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" },
|
"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",
|
"Nothing unread here": "Rien de non lu ici",
|
||||||
},
|
},
|
||||||
plurals: {
|
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é" },
|
"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" },
|
"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" },
|
"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": "ここに未読はありません",
|
"Nothing unread here": "ここに未読はありません",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
"Your administrator changed {n} settings": { other: "管理者が {n} 件の設定を変更しました" },
|
||||||
"Already here: {n} contacts, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
|
"Already here: {n} contacts, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
|
||||||
"All {n} are already in your contacts": { other: "{n} 件はすでに連絡先にあります" },
|
"All {n} are already in your contacts": { other: "{n} 件はすでに連絡先にあります" },
|
||||||
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
|
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
|
||||||
|
|||||||
@@ -1048,6 +1048,7 @@ export const catalog: Catalog = {
|
|||||||
"Nothing unread here": "Hier is niets ongelezen",
|
"Nothing unread here": "Hier is niets ongelezen",
|
||||||
},
|
},
|
||||||
plurals: {
|
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" },
|
"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" },
|
"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" },
|
"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",
|
"Nothing unread here": "Não há nada não lido aqui",
|
||||||
},
|
},
|
||||||
plurals: {
|
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" },
|
"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" },
|
"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" },
|
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
|
||||||
|
|||||||
@@ -1054,6 +1054,7 @@ export const catalog: Catalog = {
|
|||||||
"Nothing unread here": "Здесь нет непрочитанного",
|
"Nothing unread here": "Здесь нет непрочитанного",
|
||||||
},
|
},
|
||||||
plurals: {
|
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} контакта, ничего не импортировано" },
|
"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} уже в ваших контактах" },
|
"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} события" },
|
"Exported {n} events": { one: "Экспортировано {n} событие", few: "Экспортировано {n} события", many: "Экспортировано {n} событий", other: "Экспортировано {n} события" },
|
||||||
|
|||||||
@@ -1048,6 +1048,7 @@ export const catalog: Catalog = {
|
|||||||
"Nothing unread here": "Тут немає непрочитаного",
|
"Nothing unread here": "Тут немає непрочитаного",
|
||||||
},
|
},
|
||||||
plurals: {
|
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} контакти, нічого не імпортовано" },
|
"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} уже у ваших контактах" },
|
"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} події" },
|
"Exported {n} events": { one: "Експортовано {n} подію", few: "Експортовано {n} події", many: "Експортовано {n} подій", other: "Експортовано {n} події" },
|
||||||
|
|||||||
@@ -1059,6 +1059,7 @@ export const catalog: Catalog = {
|
|||||||
"Nothing unread here": "这里没有未读邮件",
|
"Nothing unread here": "这里没有未读邮件",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
"Your administrator changed {n} settings": { other: "管理员更改了 {n} 项设置" },
|
||||||
"Already here: {n} contacts, nothing imported": { other: "已存在 {n} 个,未导入" },
|
"Already here: {n} contacts, nothing imported": { other: "已存在 {n} 个,未导入" },
|
||||||
"All {n} are already in your contacts": { other: "这 {n} 个已在您的联系人中" },
|
"All {n} are already in your contacts": { other: "这 {n} 个已在您的联系人中" },
|
||||||
"Exported {n} events": { 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);
|
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 { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette";
|
||||||
import type { SortLevel, SortPreset } from "@/lib/listSort";
|
import type { SortLevel, SortPreset } from "@/lib/listSort";
|
||||||
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
|
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 { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||||
import type { SwipeAction } from "@/lib/swipe";
|
import type { SwipeAction } from "@/lib/swipe";
|
||||||
import { resolveUiLanguage } from "@/lib/languages";
|
import { resolveUiLanguage } from "@/lib/languages";
|
||||||
@@ -228,6 +228,24 @@ export interface Settings {
|
|||||||
* id belonging to another account simply never matches.
|
* id belonging to another account simply never matches.
|
||||||
*/
|
*/
|
||||||
hiddenIdentities: string[];
|
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 = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -314,6 +332,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
],
|
],
|
||||||
defaultIdentityByAccount: {},
|
defaultIdentityByAccount: {},
|
||||||
hiddenIdentities: [],
|
hiddenIdentities: [],
|
||||||
|
appliedPolicyChanges: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -411,6 +430,14 @@ interface SettingsState {
|
|||||||
* settings would be overwriting choices rather than defaulting them.
|
* settings would be overwriting choices rather than defaulting them.
|
||||||
*/
|
*/
|
||||||
seedFromPolicy(): void;
|
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);
|
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
|
||||||
@@ -463,6 +490,31 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
if (!Object.keys(defaults).length) return;
|
if (!Object.keys(defaults).length) return;
|
||||||
get().update(defaults);
|
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() {
|
reset() {
|
||||||
/* Back to how this installation starts an account, not to how ihasmail
|
/* 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
|
starts one: resetting must not be a way around a policy, and the defaults
|
||||||
|
|||||||
Reference in New Issue
Block a user