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
@@ -4,6 +4,7 @@ import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
import { t as translate, tNode } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
/**
* A swatch for each palette, drawn from the colours that palette actually
@@ -99,7 +100,7 @@ export function AppearanceSettings() {
<div className="field-row">
<div className="field">
<label>{translate("Display density")}</label>
<select className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
<select disabled={isEnforced("density")} className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
<option value="comfortable">{translate("Comfortable")}</option>
<option value="cozy">{translate("Cozy (default)")}</option>
<option value="compact">{translate("Compact")}</option>
@@ -107,7 +108,7 @@ export function AppearanceSettings() {
</div>
<div className="field">
<label>{translate("Text size")}</label>
<select className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
<select disabled={isEnforced("fontSize")} className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
<option value="small">{translate("Small")}</option>
<option value="medium">{translate("Medium")}</option>
<option value="large">{translate("Large")}</option>
@@ -192,9 +193,9 @@ export function AppearanceSettings() {
</p>
<h2>{translate("Sidebar")}</h2>
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label={translate("Show labels in the sidebar")} />
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label={translate("Show unsubscribed (hidden) folders")} />
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} />
<Switch locked={isEnforced("labelsSidebar")} checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label={translate("Show labels in the sidebar")} />
<Switch locked={isEnforced("showHiddenFolders")} checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label={translate("Show unsubscribed (hidden) folders")} />
<Switch locked={isEnforced("sidebarCollapsed")} checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} />
</div>
);
}