mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
'use client';
|
|
|
|
// Organisation > Proofreading: whether checker plugins may check what users
|
|
// write, for everyone or a scope, per checker plugin, and the writing
|
|
// language. Requiring proofreading only holds while a checker plugin can't
|
|
// be turned off, so the page warns when none is forced on and offers to
|
|
// force one (that part is global: plugins can't be forced per scope).
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { SettingItem, SettingsSection, Select } from '@/components/settings/settings-section';
|
|
import { Button } from '@/components/ui/button';
|
|
import { AlertTriangle } from '@/components/icons';
|
|
import { apiFetch } from '@/lib/browser-navigation';
|
|
import type { SettingRestriction } from '@/lib/admin/types';
|
|
import {
|
|
PLUGIN_STATES, PROOFREADING_STATES, checkersToForce, pluginProofreadingState, pluginRestrictionFor,
|
|
proofreadingPolicyFor, proofreadingState, type PluginProofreadingState, type ProofreadingState,
|
|
} from '@/lib/admin/proofreading-policy';
|
|
import { PROOFREADING_KEY, pluginPolicyKey } from '@/lib/proofreading/gate';
|
|
import { writingLanguageOptions } from '@/lib/proofreading/languages';
|
|
import { InheritNote } from '../org-page';
|
|
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
|
|
|
const LANGUAGE_KEY = 'writingLanguage';
|
|
const INHERIT = 'inherit';
|
|
|
|
interface CheckerPlugin {
|
|
id: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
forceEnabled?: boolean;
|
|
permissions?: string[];
|
|
}
|
|
|
|
/** A default at the selected scope: its own, or Everyone's. */
|
|
function presetAt(draft: OrgPolicyDraft, key: string): { value: unknown; own: boolean; everyone: unknown } {
|
|
const everyone = draft.policy?.defaults?.[key];
|
|
if (!draft.scope) return { value: everyone, own: true, everyone };
|
|
const defaults = draft.scope.policy.defaults ?? {};
|
|
return Object.prototype.hasOwnProperty.call(defaults, key)
|
|
? { value: defaults[key], own: true, everyone }
|
|
: { value: everyone, own: false, everyone };
|
|
}
|
|
|
|
function setPreset(draft: OrgPolicyDraft, key: string, value: unknown | undefined) {
|
|
if (!draft.scope) {
|
|
draft.update((d) => {
|
|
if (value === undefined) delete d.defaults[key];
|
|
else d.defaults[key] = value as never;
|
|
});
|
|
return;
|
|
}
|
|
draft.updateScope(draft.scope.id, (s) => {
|
|
const defaults = (s.policy.defaults ??= {});
|
|
if (value === undefined) delete defaults[key];
|
|
else defaults[key] = value;
|
|
if (Object.keys(defaults).length === 0) delete s.policy.defaults;
|
|
});
|
|
}
|
|
|
|
export function ProofreadingSection({ draft }: { draft: OrgPolicyDraft }) {
|
|
const [plugins, setPlugins] = useState<CheckerPlugin[] | null>(null);
|
|
const [forcing, setForcing] = useState<string | null>(null);
|
|
const [forceError, setForceError] = useState<string | null>(null);
|
|
|
|
const loadPlugins = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/api/admin/plugins');
|
|
const list = res.ok ? ((await res.json()) as CheckerPlugin[]) : [];
|
|
setPlugins(list.filter((p) => p.permissions?.includes('compose:check')));
|
|
} catch {
|
|
setPlugins([]);
|
|
}
|
|
}, []);
|
|
useEffect(() => { void loadPlugins(); }, [loadPlugins]);
|
|
|
|
const main = draft.restriction(PROOFREADING_KEY);
|
|
const mainPreset = presetAt(draft, PROOFREADING_KEY);
|
|
const mainState = proofreadingState(main.value, mainPreset.value);
|
|
const everyoneState = proofreadingState(main.everyone, mainPreset.everyone);
|
|
const mainOwn = main.own || mainPreset.own;
|
|
|
|
const writeMain = (state: ProofreadingState | typeof INHERIT) => {
|
|
if (state === INHERIT) {
|
|
draft.setRestriction(PROOFREADING_KEY, undefined);
|
|
setPreset(draft, PROOFREADING_KEY, undefined);
|
|
return;
|
|
}
|
|
const { restriction, preset } = proofreadingPolicyFor(state);
|
|
// In a scope, "no lock" must undo a lock Everyone has: an empty restriction.
|
|
draft.setRestriction(PROOFREADING_KEY, restriction ?? (draft.scope ? {} : undefined));
|
|
setPreset(draft, PROOFREADING_KEY, preset);
|
|
};
|
|
|
|
const pluginStates: Record<string, PluginProofreadingState> = {};
|
|
for (const p of plugins ?? []) pluginStates[p.id] = pluginProofreadingState(draft.restriction(pluginPolicyKey(p.id)).value);
|
|
const toForce = plugins ? checkersToForce(mainState, pluginStates, plugins) : [];
|
|
|
|
const force = async (id: string) => {
|
|
setForcing(id);
|
|
setForceError(null);
|
|
try {
|
|
const res = await apiFetch('/api/admin/plugins', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, enabled: true, forceEnabled: true }),
|
|
});
|
|
if (!res.ok) setForceError('Could not force the plugin on. Try it on its page under Plugins.');
|
|
await loadPlugins();
|
|
} finally {
|
|
setForcing(null);
|
|
}
|
|
};
|
|
|
|
const language = draft.restriction(LANGUAGE_KEY);
|
|
const languagePreset = presetAt(draft, LANGUAGE_KEY);
|
|
const languageValue = (language.value?.locked ? language.value.value : languagePreset.value) as string | undefined;
|
|
const languageLocked = language.value?.locked === true;
|
|
const writeLanguage = (value: string, locked: boolean) => {
|
|
if (!value) {
|
|
draft.setRestriction(LANGUAGE_KEY, draft.scope ? {} : undefined);
|
|
setPreset(draft, LANGUAGE_KEY, undefined);
|
|
return;
|
|
}
|
|
draft.setRestriction(LANGUAGE_KEY, locked ? { locked: true, value } as SettingRestriction : (draft.scope ? {} : undefined));
|
|
setPreset(draft, LANGUAGE_KEY, value);
|
|
};
|
|
|
|
const stateLabel = (s: ProofreadingState) => PROOFREADING_STATES.find((o) => o.value === s)?.label ?? s;
|
|
|
|
return (
|
|
<SettingsSection
|
|
id="proofreading"
|
|
title="Proofreading"
|
|
description="Proofreading plugins underline spelling and grammar mistakes while users write. The text users write (not their signature or quoted messages) goes to the service the plugin uses, never for a message that will be signed or encrypted, also when proofreading is required."
|
|
>
|
|
<SettingItem label="Proofreading" description="Off and locked hides the setting. On and required keeps it on in every composer.">
|
|
<div className="flex flex-col items-start gap-1 sm:items-end">
|
|
<Select
|
|
value={draft.scope && !mainOwn ? INHERIT : mainState}
|
|
onChange={(v) => writeMain(v as ProofreadingState | typeof INHERIT)}
|
|
options={[
|
|
...(draft.scope ? [{ value: INHERIT, label: `Same as Everyone (${stateLabel(everyoneState)})` }] : []),
|
|
...PROOFREADING_STATES.map((o) => ({ value: o.value, label: o.label })),
|
|
]}
|
|
/>
|
|
<InheritNote draft={draft} own={mainOwn} everyone={stateLabel(everyoneState)} onReset={() => writeMain(INHERIT)} />
|
|
</div>
|
|
</SettingItem>
|
|
|
|
{plugins && plugins.length === 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
No proofreading plugin is installed. Install one, such as Proofreading (LanguageTool), under Marketplace or Plugins.
|
|
</p>
|
|
)}
|
|
|
|
{plugins?.map((p) => {
|
|
const r = draft.restriction(pluginPolicyKey(p.id));
|
|
const state = pluginProofreadingState(r.value);
|
|
return (
|
|
<SettingItem key={p.id} nested label={p.name} description={p.enabled ? undefined : 'Turned off under Plugins: it checks nothing.'}>
|
|
<div className="flex flex-col items-start gap-1 sm:items-end">
|
|
<Select
|
|
value={draft.scope && !r.own ? INHERIT : state}
|
|
onChange={(v) => draft.setRestriction(
|
|
pluginPolicyKey(p.id),
|
|
v === INHERIT ? undefined : pluginRestrictionFor(v as PluginProofreadingState) ?? (draft.scope ? {} : undefined),
|
|
)}
|
|
options={[
|
|
...(draft.scope ? [{ value: INHERIT, label: 'Same as Everyone' }] : []),
|
|
...PLUGIN_STATES.map((o) => ({ value: o.value, label: o.label })),
|
|
]}
|
|
ariaLabel={`Proofreading with ${p.name}`}
|
|
/>
|
|
</div>
|
|
</SettingItem>
|
|
);
|
|
})}
|
|
|
|
{toForce.length > 0 && (
|
|
<div role="status" data-testid="proofreading-force-warning" className="flex flex-col gap-2 rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
|
|
<p className="flex items-start gap-2">
|
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
|
<span>
|
|
Proofreading is required, but no proofreading plugin is forced on, so users can turn the plugin off under
|
|
Settings > Add-ons and the check with it. Forcing a plugin on applies to everyone, not only this scope.
|
|
</span>
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{toForce.map((id) => {
|
|
const p = plugins!.find((x) => x.id === id)!;
|
|
return (
|
|
<Button key={id} variant="outline" size="xs" disabled={forcing !== null} onClick={() => void force(id)}>
|
|
Force {p.name} on for everyone
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
{forceError && <p className="text-xs text-destructive">{forceError}</p>}
|
|
</div>
|
|
)}
|
|
|
|
<SettingItem label="Writing language" description="Presets the language users write in; proofreading and the browser's spell check use it. Locked, users can't change it.">
|
|
<div className="flex flex-col items-start gap-1 sm:items-end">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Select
|
|
value={languageValue ?? ''}
|
|
onChange={(v) => writeLanguage(v, languageLocked)}
|
|
options={[{ value: '', label: 'Users choose' }, { value: 'auto', label: 'Detect automatically' }, ...writingLanguageOptions('en')]}
|
|
ariaLabel="Writing language"
|
|
/>
|
|
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
className="rounded border-input"
|
|
checked={languageLocked}
|
|
disabled={!languageValue}
|
|
onChange={(e) => writeLanguage(languageValue ?? '', e.target.checked)}
|
|
/>
|
|
Lock
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</SettingItem>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
For a fully mandatory setup: force the plugin on, set Proofreading to "On, required" and, in the plugin's
|
|
settings, turn off users changing its language and rules. A plugin that reaches a server on your network needs that
|
|
address in PLUGIN_PROXY_PRIVATE_ORIGINS.
|
|
</p>
|
|
</SettingsSection>
|
|
);
|
|
}
|