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,82 @@
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { DEFAULT_SETTINGS, type Settings } from "@/store/settings";
|
||||
|
||||
/**
|
||||
* What the installation has decided about settings, rather than the reader.
|
||||
*
|
||||
* Two powers, from #207. `defaults` seed an account that has never had settings
|
||||
* of its own and can be changed afterwards like anything else. `enforced` are
|
||||
* applied on every load and cannot be changed here at all -- their controls stay
|
||||
* visible and go dead, which is what the issue asked for: hiding them confuses
|
||||
* somebody who has used ihasmail somewhere without a policy.
|
||||
*
|
||||
* Fetched once. `/api/config` is unauthenticated and already fetched by the
|
||||
* sign-in page, so this costs nothing on a cold load and is available before
|
||||
* anybody's settings are read.
|
||||
*/
|
||||
export interface SettingsPolicy {
|
||||
defaults: Partial<Settings>;
|
||||
enforced: Partial<Settings>;
|
||||
}
|
||||
|
||||
const EMPTY: SettingsPolicy = { defaults: {}, enforced: {} };
|
||||
|
||||
let policy: SettingsPolicy = EMPTY;
|
||||
let fetched: Promise<SettingsPolicy> | null = null;
|
||||
|
||||
/**
|
||||
* Keys the installation names that this build does not have.
|
||||
*
|
||||
* A policy written against a newer ihasmail, or with a typo in it, must not
|
||||
* introduce a setting that nothing reads: `update` would carry it around and
|
||||
* `syncedPart` would push it to the reader's settings file for ever. Anything
|
||||
* not in `DEFAULT_SETTINGS` is dropped, which is the same rule `importJson`
|
||||
* already applies to a settings file somebody hands us.
|
||||
*/
|
||||
function known(obj: Record<string, unknown>): Partial<Settings> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) if (k in DEFAULT_SETTINGS) out[k] = v;
|
||||
return out as Partial<Settings>;
|
||||
}
|
||||
|
||||
export async function loadSettingsPolicy(): Promise<SettingsPolicy> {
|
||||
if (fetched) return fetched;
|
||||
fetched = (async () => {
|
||||
try {
|
||||
const res = await fetch(withBase("/api/config"), { credentials: "same-origin" });
|
||||
if (!res.ok) return EMPTY;
|
||||
const body = (await res.json()) as { settingsPolicy?: { defaults?: Record<string, unknown>; enforced?: Record<string, unknown> } };
|
||||
policy = {
|
||||
defaults: known(body.settingsPolicy?.defaults ?? {}),
|
||||
enforced: known(body.settingsPolicy?.enforced ?? {}),
|
||||
};
|
||||
return policy;
|
||||
} catch {
|
||||
/* No policy is the ordinary case and an unreachable one must not stop a
|
||||
sign-in: an installation that sets nothing looks exactly like this. */
|
||||
return EMPTY;
|
||||
}
|
||||
})();
|
||||
return fetched;
|
||||
}
|
||||
|
||||
/** What the installation has settled, for a reader who has none of their own. */
|
||||
export function policyDefaults(): Partial<Settings> {
|
||||
return policy.defaults;
|
||||
}
|
||||
|
||||
/** What the installation has settled that a reader may not change. */
|
||||
export function policyEnforced(): Partial<Settings> {
|
||||
return policy.enforced;
|
||||
}
|
||||
|
||||
/** Whether this setting belongs to the administrator rather than the reader. */
|
||||
export function isEnforced(key: keyof Settings): boolean {
|
||||
return key in policy.enforced;
|
||||
}
|
||||
|
||||
/** Only for tests: forget what was fetched. */
|
||||
export function resetSettingsPolicyForTest(next: SettingsPolicy = EMPTY): void {
|
||||
policy = { defaults: known(next.defaults as Record<string, unknown>), enforced: known(next.enforced as Record<string, unknown>) };
|
||||
fetched = Promise.resolve(policy);
|
||||
}
|
||||
Reference in New Issue
Block a user