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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user