Let an installation seed and lock user settings

The first two thirds of #207. A school wanting "warn about outside senders"
on for three thousand pupils cannot ask three thousand pupils, and the
reporter is right that this is a company policy rather than a preference.

Two powers, and the difference between them is the whole request. `defaults`
seed an account that has never had settings of its own and can be changed
afterwards like anything else -- a starting point, not a rule. `enforced` are
reapplied on every load and cannot be changed at all.

Enforced controls stay visible and go dead, with a line saying why. The issue
asked for that by name: a control that is simply missing reads as a bug to
somebody who has used ihasmail without a policy.

The lock is in the settings store rather than only on the controls. There is
one door -- `update` -- and putting it there means an imported settings file,
a settings file synced from a device that predates the policy, and a control
somebody adds later and forgets to check are all covered by construction.
Reset goes back to the installation's answer rather than to ihasmail's, so it
cannot be a way around a policy either.

Configured by environment variable or by a file, because ihasmail's own
production runs read-only with no volume: an installation that cannot mount a
file can still set a variable. Keys this build does not have are dropped, the
same rule an imported settings file already gets -- a policy written against a
newer ihasmail must not put a setting nothing reads into everybody's synced
settings file. Malformed JSON stops the server rather than quietly doing
nothing, since a policy that silently did not apply is indistinguishable from
the feature not working.

Tier three -- enforcing a setting once while still letting readers change it
afterwards -- is not here. It needs a decision the reporter and I have not
made yet, and it is the only part that stores anything new.

Refs #207.
This commit is contained in:
2026-09-02 10:49:55 -07:00
parent a01e1874d8
commit 457ea53ca3
22 changed files with 396 additions and 45 deletions
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import { isEnforced, policyDefaults, policyEnforced, resetSettingsPolicyForTest } from "@/lib/settingsPolicy";
/*
* Settings an installation decides, from #207.
*
* A school turning on "warn about outside senders" for three thousand pupils
* cannot ask three thousand pupils. Two powers, and the difference between them
* is the whole point: defaults are a starting point the reader may change,
* enforced settings are not.
*/
vi.mock("@/lib/settingsSync", () => ({
queueSettingsPush: vi.fn(),
pendingSettingsKeys: () => new Set<string>(),
}));
beforeEach(() => {
resetSettingsPolicyForTest();
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
});
afterEach(() => {
resetSettingsPolicyForTest();
vi.restoreAllMocks();
});
describe("what the installation has decided", () => {
it("keeps to the settings this build actually has", () => {
/*
* A policy written against a newer ihasmail, or with a typo in it, must not
* introduce a key nothing reads: it would be carried around and pushed to
* the reader's settings file for ever. Same rule an imported settings file
* already gets.
*/
resetSettingsPolicyForTest({
defaults: { conversationMode: false, notARealSetting: true } as never,
enforced: { alsoNotReal: 1 } as never,
});
expect(policyDefaults()).toEqual({ conversationMode: false });
expect(policyEnforced()).toEqual({});
});
it("says which settings belong to the administrator", () => {
// The setting the issue was actually about: the outside-sender banner.
resetSettingsPolicyForTest({ defaults: {}, enforced: { externalSenderBanner: true } as never });
expect(isEnforced("externalSenderBanner")).toBe(true);
expect(isEnforced("conversationMode")).toBe(false);
});
});
describe("defaults, for an account that has none of its own", () => {
it("seeds them", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().seedFromPolicy();
expect(useSettings.getState().settings.conversationMode).toBe(false);
});
it("leaves everything it does not name alone", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().seedFromPolicy();
expect(useSettings.getState().settings.showAvatars).toBe(DEFAULT_SETTINGS.showAvatars);
});
it("can still be changed afterwards, being a starting point and not a rule", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().seedFromPolicy();
useSettings.getState().update({ conversationMode: true });
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("does nothing at all when the installation has set none", () => {
const before = useSettings.getState().settings;
useSettings.getState().seedFromPolicy();
expect(useSettings.getState().settings).toBe(before);
});
});
describe("enforced settings, which the reader may not change", () => {
beforeEach(() => {
resetSettingsPolicyForTest({ defaults: {}, enforced: { conversationMode: true } as never });
});
it("survives an update that tries to change it", () => {
useSettings.getState().update({ conversationMode: false });
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("does not stop the rest of that same update", () => {
// The one key is refused; the others are the reader's business.
useSettings.getState().update({ conversationMode: false, showAvatars: false });
expect(useSettings.getState().settings.conversationMode).toBe(true);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("survives a settings file arriving from another device", () => {
// An older sign-in wrote past the policy before it existed. Hydrating must
// not put that back.
useSettings.getState().hydrate({ conversationMode: false, showAvatars: false });
expect(useSettings.getState().settings.conversationMode).toBe(true);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("survives a reset", () => {
// Resetting must not be the way around a policy.
useSettings.getState().reset();
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("survives an imported settings file", () => {
useSettings.getState().importJson(JSON.stringify({ conversationMode: false }));
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
});
describe("reset, where the installation has chosen defaults", () => {
it("goes back to the installation's answer rather than to ihasmail's", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().update({ conversationMode: true });
useSettings.getState().reset();
expect(useSettings.getState().settings.conversationMode).toBe(false);
});
});
+36 -8
View File
@@ -4,6 +4,7 @@ import { hasCachedJson, loadJson, saveJson } from "@/lib/storage";
import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette";
import type { SortLevel, SortPreset } from "@/lib/listSort";
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
import { policyDefaults, policyEnforced } from "@/lib/settingsPolicy";
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/swipe";
import { resolveUiLanguage } from "@/lib/languages";
@@ -402,6 +403,14 @@ interface SettingsState {
importJson(json: string): boolean;
/** Apply the account's settings file over the cached ones. */
hydrate(remote: Record<string, unknown>): void;
/**
* Seed an account that has never had settings of its own.
*
* Only for that case, which is why it is not `update`: these are a starting
* point the reader may change, so applying them to somebody who already has
* settings would be overwriting choices rather than defaulting them.
*/
seedFromPolicy(): void;
}
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
@@ -428,7 +437,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
* change the theme cannot forget to update it and strand an older device
* on a theme nobody picked.
*/
const merged = { ...get().settings, ...patch };
/*
* Enforcement lives here rather than only on the controls. The controls are
* disabled and say why, which is the part a reader sees -- but a setting
* the installation has decided must not be changeable through an imported
* settings file, a keyboard shortcut, or a control somebody adds later and
* forgets to check. There is one door, so the lock is on it. Issue #207.
*/
const merged = { ...get().settings, ...patch, ...policyEnforced() };
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
const settings = { ...merged, theme: legacyTheme({ palette: merged.palette, mode: merged.mode }, prefersDark) };
saveJson("settings", settings);
@@ -442,13 +458,22 @@ export const useSettings = create<SettingsState>((set, get) => ({
queueSettingsPush(syncedPart(settings));
}
},
seedFromPolicy() {
const defaults = policyDefaults();
if (!Object.keys(defaults).length) return;
get().update(defaults);
},
reset() {
saveJson("settings", DEFAULT_SETTINGS);
set({ settings: DEFAULT_SETTINGS });
applyTheme(DEFAULT_SETTINGS);
applyDateTimePrefs(DEFAULT_SETTINGS);
applyLang(DEFAULT_SETTINGS);
queueSettingsPush(syncedPart(DEFAULT_SETTINGS));
/* Back to how this installation starts an account, not to how ihasmail
starts one: resetting must not be a way around a policy, and the defaults
an admin chose are the honest meaning of "reset" where there are any. */
const base = { ...DEFAULT_SETTINGS, ...policyDefaults(), ...policyEnforced() };
saveJson("settings", base);
set({ settings: base });
applyTheme(base);
applyDateTimePrefs(base);
applyLang(base);
queueSettingsPush(syncedPart(base));
},
exportJson() {
return JSON.stringify(get().settings, null, 2);
@@ -463,7 +488,10 @@ export const useSettings = create<SettingsState>((set, get) => ({
}
},
hydrate(remote) {
const settings = mergeRemote(get().settings, remote, pendingSettingsKeys());
/* Enforced values win over what the account's own file says: a policy that
an older sign-in has already written past would otherwise stay written
past for ever. */
const settings = { ...mergeRemote(get().settings, remote, pendingSettingsKeys()), ...policyEnforced() };
// Cache it, so the next first frame on this browser is already right.
saveJson("settings", settings);
set({ settings });