Apply installation policy changes once each, per account

The last third of #207, and the only part that remembers anything.

An admin turns a setting on for people who are already here -- which a default
cannot do, since a default only seeds an account that has none -- and readers
may still turn it back off afterwards, which enforcement does not allow. The
difference between the two is entirely in the remembering.

Each change carries its own version, and an account stores the ones it has had
in its own settings file. Ids rather than a high-water mark, so a change dated
earlier than one already applied is not silently skipped -- the reporter's
analogy is a schema migration, and this is that shape.

Per account rather than per device, because ihasmail's settings are not
browser-local: they live in a file in the reader's own JMAP Files, with the
browser holding a cache. Signing in on a phone does not apply everything a
second time.

A change reaches somebody who had already decided otherwise. That is intended
and confirmed on the issue: the point is to reach everybody who is already
here. It is applied once, and their next decision sticks.

One `update` for however many are pending, since each would otherwise push a
settings file of its own. Enforced values still win, being applied after. A
change whose settings this build does not have at all is dropped rather than
recorded, or it would never run on the ihasmail that does have them.

The reader is told. A setting moving under somebody without a word is the part
of this worth being uneasy about, so the count is toasted with a way into
Settings.

README gains the Docker half the user asked for: a mounted policy file, the
same thing as environment variables for a deployment with no volume, a compose
fragment, and the fact that a policy is read once at startup so editing it
means a restart.

Closes #207.
This commit is contained in:
2026-09-02 11:00:48 -07:00
parent 6821d6a93f
commit c31a653a04
15 changed files with 327 additions and 25 deletions
+53 -1
View File
@@ -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