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:
@@ -117,6 +117,46 @@ nowhere to live across a restart. Removing it means moving the session upstream
|
||||
into a token Stalwart itself issues and can revoke, which is what the OAuth work
|
||||
in [ROADMAP.md](ROADMAP.md) is for.
|
||||
|
||||
### Settings the installation decides
|
||||
|
||||
A deployment can seed and lock user settings, which is what a school wanting
|
||||
"warn about outside senders" on for three thousand pupils needs — asking three
|
||||
thousand pupils is not a plan.
|
||||
|
||||
```bash
|
||||
-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
|
||||
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}'
|
||||
```
|
||||
|
||||
Or, where mounting a file is easier than quoting JSON in a unit file:
|
||||
|
||||
```bash
|
||||
-e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": { "externalSenderBanner": true },
|
||||
"enforced": { "externalRecipientConfirm": true }
|
||||
}
|
||||
```
|
||||
|
||||
The two are different powers. **`defaults`** seed an account that has never had
|
||||
settings of its own; the reader can change any of them afterwards, and they are
|
||||
a starting point rather than a rule. **`enforced`** are reapplied on every load
|
||||
and cannot be changed at all — their controls stay visible in Settings and go
|
||||
dead with a line saying why, because a control that is simply missing reads as a
|
||||
bug to anyone who has used ihasmail without a policy.
|
||||
|
||||
Both are given in the same names and values a settings export uses, so
|
||||
`Settings → General → Export` on a configured account is the quickest way to
|
||||
write one. Keys this build does not have are ignored rather than stored, and
|
||||
malformed JSON stops the server at startup rather than silently doing nothing.
|
||||
|
||||
Enforcement is applied in the settings store rather than only on the controls,
|
||||
so an imported settings file or a settings file synced from a device that
|
||||
predates the policy cannot get around it.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@@ -175,6 +175,9 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
sourceUrl: config.sourceUrl,
|
||||
imageProxy: config.imageProxy,
|
||||
maxUploadBytes: config.maxUploadBytes,
|
||||
/* Sent before sign-in like the rest of this: it says what the
|
||||
installation has decided, not anything about who is asking. */
|
||||
settingsPolicy: config.settingsPolicy,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -111,9 +111,56 @@ export function assertImmutable(sessionFile: string, root: string): void {
|
||||
if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url)));
|
||||
|
||||
|
||||
/**
|
||||
* Settings an installation decides, rather than each reader.
|
||||
*
|
||||
* A school turning on "warn about outside senders" for three thousand pupils
|
||||
* cannot ask three thousand pupils to turn it on -- issue #207. Two sections,
|
||||
* which are two different powers:
|
||||
*
|
||||
* - `defaults` seed an account that has never had settings of its own. The
|
||||
* reader can change any of them afterwards; they are a starting point, not a
|
||||
* rule.
|
||||
* - `enforced` are applied on every load and cannot be changed here at all. The
|
||||
* controls stay visible and go dead, which the issue asked for by name: a
|
||||
* missing control confuses somebody who has used ihasmail elsewhere.
|
||||
*
|
||||
* Read from a file or straight from the environment, because ihasmail's own
|
||||
* production runs read-only with no volume -- an installation that cannot mount
|
||||
* a file can still set a variable.
|
||||
*/
|
||||
function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Record<string, unknown> } {
|
||||
const parse = (raw: string, where: string): Record<string, unknown> => {
|
||||
try {
|
||||
const v = JSON.parse(raw) as unknown;
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
|
||||
return v as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
/* Loud, and fatal. A policy that silently did not apply would look like
|
||||
the feature not working, and the admin would have no way to tell. */
|
||||
throw new Error(`Invalid ${where}: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const file = process.env.SETTINGS_POLICY_FILE;
|
||||
if (file) {
|
||||
if (!existsSync(file)) throw new Error(`SETTINGS_POLICY_FILE does not exist: ${file}`);
|
||||
const whole = parse(readFileSync(file, "utf8"), `SETTINGS_POLICY_FILE (${file})`);
|
||||
return {
|
||||
defaults: (whole.defaults as Record<string, unknown>) ?? {},
|
||||
enforced: (whole.enforced as Record<string, unknown>) ?? {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
defaults: process.env.SETTINGS_DEFAULTS ? parse(process.env.SETTINGS_DEFAULTS, "SETTINGS_DEFAULTS") : {},
|
||||
enforced: process.env.SETTINGS_ENFORCED ? parse(process.env.SETTINGS_ENFORCED, "SETTINGS_ENFORCED") : {},
|
||||
};
|
||||
}
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
settingsPolicy: readSettingsPolicy(),
|
||||
/**
|
||||
* What this build calls itself: `2.16.57`. Set by the image build from
|
||||
* `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { setUnreadBadge } from "@/lib/notify";
|
||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
|
||||
import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
||||
@@ -145,9 +146,17 @@ function AuthedApp() {
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
/* Before the account's own settings, so both the seeding below and the
|
||||
enforcement inside `hydrate` have something to apply. */
|
||||
await loadSettingsPolicy();
|
||||
if (cancelled) return;
|
||||
const remote = await loadRemoteSettings();
|
||||
if (cancelled) return;
|
||||
if (remote) useSettings.getState().hydrate(remote);
|
||||
// No settings file: this account has never had settings of its own, so
|
||||
// the installation's defaults are what it starts on rather than
|
||||
// ihasmail's. Issue #207.
|
||||
else useSettings.getState().seedFromPolicy();
|
||||
// The catalogue for whatever language that turned out to be. Hydrating
|
||||
// asks for it; this is waiting for the answer.
|
||||
await whenLanguageReady();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Set for everyone here. You cannot change this.": "Für alle hier festgelegt. Sie können dies nicht ändern.",
|
||||
"Export iCAL file": "iCAL-Datei exportieren",
|
||||
"Could not export this calendar: {error}": "Dieser Kalender konnte nicht exportiert werden: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,6 +47,7 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Set for everyone here. You cannot change this.": "Definido para todos aquí. No puedes cambiarlo.",
|
||||
"Export iCAL file": "Exportar archivo iCAL",
|
||||
"Could not export this calendar: {error}": "No se pudo exportar este calendario: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -52,6 +52,7 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Set for everyone here. You cannot change this.": "Défini pour tout le monde ici. Vous ne pouvez pas le modifier.",
|
||||
"Export iCAL file": "Exporter un fichier iCAL",
|
||||
"Could not export this calendar: {error}": "Impossible d’exporter ce calendrier : {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,6 +46,7 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Set for everyone here. You cannot change this.": "この環境全体で設定されています。変更できません。",
|
||||
"Export iCAL file": "iCAL ファイルをエクスポート",
|
||||
"Could not export this calendar: {error}": "このカレンダーをエクスポートできませんでした: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,6 +43,7 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.",
|
||||
"Export iCAL file": "iCAL-bestand exporteren",
|
||||
"Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,6 +50,7 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Set for everyone here. You cannot change this.": "Definido para todos aqui. Você não pode alterar isto.",
|
||||
"Export iCAL file": "Exportar arquivo iCAL",
|
||||
"Could not export this calendar: {error}": "Não foi possível exportar esta agenda: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,6 +49,7 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Set for everyone here. You cannot change this.": "Задано для всех здесь. Изменить нельзя.",
|
||||
"Export iCAL file": "Экспортировать файл iCAL",
|
||||
"Could not export this calendar: {error}": "Не удалось экспортировать этот календарь: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,6 +43,7 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Set for everyone here. You cannot change this.": "Задано для всіх тут. Змінити не можна.",
|
||||
"Export iCAL file": "Експортувати файл iCAL",
|
||||
"Could not export this calendar: {error}": "Не вдалося експортувати цей календар: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -45,6 +45,7 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Set for everyone here. You cannot change this.": "已为此处所有人设定,您无法更改。",
|
||||
"Export iCAL file": "导出 iCAL 文件",
|
||||
"Could not export this calendar: {error}": "无法导出此日历:{error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+6
-2
@@ -20,9 +20,9 @@ export function Avatar({ who, size, className }: { who: EmailAddress | { name?:
|
||||
);
|
||||
}
|
||||
|
||||
export function Switch({ checked, onChange, label, hint, disabled }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode; hint?: ReactNode; disabled?: boolean }) {
|
||||
export function Switch({ checked, onChange, label, hint, disabled, locked }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode; hint?: ReactNode; disabled?: boolean; locked?: boolean }) {
|
||||
const sw = (
|
||||
<button type="button" role="switch" aria-checked={checked} className="switch" onClick={() => !disabled && onChange(!checked)} disabled={disabled} />
|
||||
<button type="button" role="switch" aria-checked={checked} className="switch" onClick={() => !disabled && !locked && onChange(!checked)} disabled={disabled || locked} />
|
||||
);
|
||||
if (!label) return sw;
|
||||
return (
|
||||
@@ -30,6 +30,10 @@ export function Switch({ checked, onChange, label, hint, disabled }: { checked:
|
||||
<div className="switch-text">
|
||||
<span>{label}</span>
|
||||
{hint && <span className="hint">{hint}</span>}
|
||||
{/* Shown rather than hidden, and said rather than implied: a control
|
||||
that is simply missing reads as a bug to somebody who has used
|
||||
ihasmail without a policy. Issue #207. */}
|
||||
{locked && <span className="hint">{t("Set for everyone here. You cannot change this.")}</span>}
|
||||
</div>
|
||||
{sw}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ColorSwatches, CALENDAR_COLORS, Switch } from "@/ui/misc";
|
||||
import { promptDialog } from "@/ui/dialog";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { isEnforced } from "@/lib/settingsPolicy";
|
||||
|
||||
export function CalendarSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
@@ -14,7 +15,7 @@ export function CalendarSettings() {
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>{t("Default view")}</label>
|
||||
<select className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}>
|
||||
<select disabled={isEnforced("calendarDefaultView")} className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}>
|
||||
<option value="day">{t("Day")}</option>
|
||||
<option value="week">{t("Week")}</option>
|
||||
<option value="month">{t("Month")}</option>
|
||||
@@ -23,7 +24,7 @@ export function CalendarSettings() {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("Default event length")}</label>
|
||||
<select className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}>
|
||||
<select disabled={isEnforced("defaultEventDuration")} className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}>
|
||||
<option value="15">{t("15 minutes")}</option>
|
||||
<option value="30">{t("30 minutes")}</option>
|
||||
<option value="45">{t("45 minutes")}</option>
|
||||
@@ -34,7 +35,7 @@ export function CalendarSettings() {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("Default reminder")}</label>
|
||||
<select className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}>
|
||||
<select disabled={isEnforced("defaultAlertMinutes")} className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}>
|
||||
<option value="-1">{t("None")}</option>
|
||||
<option value="0">{t("At time of event")}</option>
|
||||
<option value="5">{t("5 minutes before")}</option>
|
||||
@@ -116,19 +117,19 @@ export function CalendarSettings() {
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>{t("Working hours start")}</label>
|
||||
<select className="select" value={String(s.workDayStart)} onChange={(e) => update({ workDayStart: Number(e.target.value) })}>
|
||||
<select disabled={isEnforced("workDayStart")} className="select" value={String(s.workDayStart)} onChange={(e) => update({ workDayStart: Number(e.target.value) })}>
|
||||
{[...Array(24)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("Working hours end")}</label>
|
||||
<select className="select" value={String(s.workDayEnd)} onChange={(e) => update({ workDayEnd: Number(e.target.value) })}>
|
||||
<select disabled={isEnforced("workDayEnd")} className="select" value={String(s.workDayEnd)} onChange={(e) => update({ workDayEnd: Number(e.target.value) })}>
|
||||
{[...Array(25)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("Week starts on")}</label>
|
||||
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
|
||||
<select disabled={isEnforced("weekStart")} className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
|
||||
<option value="1">{t("Monday")}</option>
|
||||
<option value="0">{t("Sunday")}</option>
|
||||
<option value="6">{t("Saturday")}</option>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
withPrefs,
|
||||
type DateFormat,
|
||||
} from "@/lib/datetime";
|
||||
import { isEnforced } from "@/lib/settingsPolicy";
|
||||
|
||||
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
|
||||
const SAMPLE = new Date(2025, 10, 22, 18, 23);
|
||||
@@ -69,7 +70,7 @@ export function GeneralSettings() {
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>{t("Reading pane")}</label>
|
||||
<select className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}>
|
||||
<select disabled={isEnforced("readingPane")} className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}>
|
||||
<option value="right">{t("Right of the list")}</option>
|
||||
<option value="bottom">{t("Below the list")}</option>
|
||||
<option value="off">{t("Off (open messages full width)")}</option>
|
||||
@@ -77,7 +78,7 @@ export function GeneralSettings() {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("Mark as read")}</label>
|
||||
<select className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
|
||||
<select disabled={isEnforced("markReadDelay")} className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
|
||||
<option value="0">{t("Immediately when opened")}</option>
|
||||
<option value="2">{t("After 2 seconds")}</option>
|
||||
<option value="5">{t("After 5 seconds")}</option>
|
||||
@@ -86,21 +87,21 @@ export function GeneralSettings() {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("After archiving or deleting")}</label>
|
||||
<select className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}>
|
||||
<select disabled={isEnforced("autoAdvance")} className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}>
|
||||
<option value="list">{t("Go back to the list")}</option>
|
||||
<option value="older">{t("Open the next (older) conversation")}</option>
|
||||
<option value="newer">{t("Open the previous (newer) conversation")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} />
|
||||
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} />
|
||||
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
|
||||
<Switch locked={isEnforced("conversationMode")} checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} />
|
||||
<Switch locked={isEnforced("showPreview")} checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} />
|
||||
<Switch locked={isEnforced("showAvatars")} checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>{t("Message order")}</label>
|
||||
<select className="select" value={s.listSortPreset} onChange={(e) => update({ listSortPreset: e.target.value as SortPreset })}>
|
||||
<select disabled={isEnforced("listSortPreset")} className="select" value={s.listSortPreset} onChange={(e) => update({ listSortPreset: e.target.value as SortPreset })}>
|
||||
<option value="newest">{t("Newest first")}</option>
|
||||
<option value="oldest">{t("Oldest first")}</option>
|
||||
<option value="unreadFirst">{t("Unread first")}</option>
|
||||
@@ -113,7 +114,7 @@ export function GeneralSettings() {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("Applies to")}</label>
|
||||
<select className="select" value={s.listSortScope} onChange={(e) => update({ listSortScope: e.target.value as "inbox" | "all" })}>
|
||||
<select disabled={isEnforced("listSortScope")} className="select" value={s.listSortScope} onChange={(e) => update({ listSortScope: e.target.value as "inbox" | "all" })}>
|
||||
<option value="inbox">{t("The Inbox only")}</option>
|
||||
<option value="all">{t("Every folder")}</option>
|
||||
</select>
|
||||
@@ -172,15 +173,15 @@ export function GeneralSettings() {
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>{t("Default format")}</label>
|
||||
<select className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}>
|
||||
<select disabled={isEnforced("composeFormat")} className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}>
|
||||
<option value="html">{t("Rich text (HTML)")}</option>
|
||||
<option value="text">{t("Plain text")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
|
||||
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
|
||||
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
|
||||
<Switch locked={isEnforced("includeQuote")} checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
|
||||
<Switch locked={isEnforced("signatureAboveQuote")} checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
|
||||
<Switch locked={isEnforced("spellcheck")} checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
|
||||
|
||||
<h2>{t("Locale")}</h2>
|
||||
<div className="field-row">
|
||||
@@ -193,7 +194,7 @@ export function GeneralSettings() {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("Week starts on")}</label>
|
||||
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
|
||||
<select disabled={isEnforced("weekStart")} className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
|
||||
<option value="1">{t("Monday")}</option>
|
||||
<option value="0">{t("Sunday")}</option>
|
||||
<option value="6">{t("Saturday")}</option>
|
||||
@@ -203,7 +204,7 @@ export function GeneralSettings() {
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>{t("Language & region")}</label>
|
||||
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
|
||||
<select disabled={isEnforced("locale")} className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
|
||||
<option value="">{t("Automatic ({locale})", { locale: localeLabel(autoLocale) })}</option>
|
||||
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} — {o.tag}</option>)}
|
||||
</select>
|
||||
@@ -211,7 +212,7 @@ export function GeneralSettings() {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("Date format")}</label>
|
||||
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
|
||||
<select disabled={isEnforced("dateFormat")} className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
|
||||
{DATE_FORMATS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{t(f.label)} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
|
||||
@@ -221,7 +222,7 @@ export function GeneralSettings() {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{t("Time format")}</label>
|
||||
<select className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}>
|
||||
<select disabled={isEnforced("timeFormat")} className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}>
|
||||
<option value="auto">{t("Automatic ({example})", { example: withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE)) })}</option>
|
||||
<option value="24">{t("24-hour clock (18:23)")}</option>
|
||||
<option value="12">{t("12-hour clock (6:23 PM)")}</option>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnabl
|
||||
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { isEnforced } from "@/lib/settingsPolicy";
|
||||
|
||||
export function NotificationsSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
@@ -76,7 +77,7 @@ export function NotificationsSettings() {
|
||||
: t("Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.")
|
||||
}
|
||||
/>
|
||||
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label={t("Play a sound for new mail")} />
|
||||
<Switch locked={isEnforced("notificationSound")} checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label={t("Play a sound for new mail")} />
|
||||
<div className="row mt-16">
|
||||
<button className="btn" onClick={() => { showNotification(t("ihasmail test"), { body: t("This is what a new-mail notification looks like.") }); playNewMailSound(); }}>{t("Test notification")}</button>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { domainOf } from "@/lib/address";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { X } from "lucide-react";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { isEnforced } from "@/lib/settingsPolicy";
|
||||
|
||||
/**
|
||||
* Everything about what reaches a sender, and what asks before it happens.
|
||||
@@ -36,7 +37,7 @@ export function PrivacySettings() {
|
||||
<h2>{t("Remote content")}</h2>
|
||||
<div className="field">
|
||||
<label>{t("Remote images")}</label>
|
||||
<select className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
|
||||
<select disabled={isEnforced("imagePolicy")} className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
|
||||
<option value="ask">{t("Ask before showing (recommended)")}</option>
|
||||
<option value="contacts">{t("Show automatically from my contacts")}</option>
|
||||
<option value="always">{t("Always show")}</option>
|
||||
@@ -67,10 +68,10 @@ export function PrivacySettings() {
|
||||
)}
|
||||
|
||||
<h2>{t("Read receipts")}</h2>
|
||||
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label={t("Always request read receipts")} />
|
||||
<Switch locked={isEnforced("requestReadReceipt")} checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label={t("Always request read receipts")} />
|
||||
<div className="field">
|
||||
<label>{t("When someone requests a read receipt")}</label>
|
||||
<select className="select" value={s.readReceiptPolicy} onChange={(e) => update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}>
|
||||
<select disabled={isEnforced("readReceiptPolicy")} className="select" value={s.readReceiptPolicy} onChange={(e) => update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}>
|
||||
<option value="ask">{t("Ask me on each message")}</option>
|
||||
<option value="never">{t("Never send one")}</option>
|
||||
</select>
|
||||
@@ -108,7 +109,7 @@ export function PrivacySettings() {
|
||||
|
||||
<div className="field">
|
||||
<label>{t("Ask before sending to a large group")}</label>
|
||||
<select className="select" value={String(s.replyAllThreshold)} onChange={(e) => update({ replyAllThreshold: Number(e.target.value) })}>
|
||||
<select disabled={isEnforced("replyAllThreshold")} className="select" value={String(s.replyAllThreshold)} onChange={(e) => update({ replyAllThreshold: Number(e.target.value) })}>
|
||||
<option value="0">{t("Never ask")}</option>
|
||||
<option value="5">{t("5 people or more")}</option>
|
||||
<option value="10">{t("10 people or more")}</option>
|
||||
@@ -136,7 +137,7 @@ export function PrivacySettings() {
|
||||
<h2>{t("Before it happens")}</h2>
|
||||
<div className="field">
|
||||
<label>{t("Undo send window")}</label>
|
||||
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
|
||||
<select disabled={isEnforced("undoSendSeconds")} className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
|
||||
<option value="0">{t("Off")}</option>
|
||||
<option value="5">{t("5 seconds")}</option>
|
||||
<option value="8">{t("8 seconds")}</option>
|
||||
@@ -145,8 +146,8 @@ export function PrivacySettings() {
|
||||
</select>
|
||||
<p className="hint">{t("The message is held in this browser and has not been submitted yet, so taking it back costs nothing.")}</p>
|
||||
</div>
|
||||
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} />
|
||||
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} />
|
||||
<Switch locked={isEnforced("attachmentReminder")} checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} />
|
||||
<Switch locked={isEnforced("confirmDelete")} checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user