Files
Linus Rath 6055dac1cb fix: keep proofreading text from plugins that shouldn't get it, and work it by keyboard #1397
A security and an accessibility review of the proofreading hook found
gaps, fixed here.

What leaves the browser:
- A checker gets a name for each composer of its own, not the composer's
  id, so it can't reach the message through the other api.composer calls
  after proofreading stops.
- Checking pauses while a plugin that can sign or encrypt is turned on but
  still starting, and while the policy couldn't be fetched (the built-in
  defaults could undo an admin's lock).
- No plugin can hold up turning proofreading off (onBeforeSettingChange).
- Suggestions can't carry zero-width, bidi or line-separator characters;
  messages lose bidi overrides; a flood of annotations is cut off early.
- The notices name the plugin as installed, not a label it chose, and say
  that text written before signing or encryption was turned on may
  already have been checked.
- A checker registered during start-up no longer fails its first check.

Keyboard and screen readers:
- Escape in the issue panel closes the panel, not the message.
- Underlines carry aria-invalid, as the browser's own markers do.
- The focus goes somewhere sensible when a button or an issue goes away,
  Tab past the popup's edges goes back to the text, and the shortcuts work
  in the popup and the panel.
- Ctrl+' and Ctrl+; follow the character typed, so other layouts keep
  their own keys (Ctrl+S on Dvorak), with Ä and Ö on German keyboards.
- The popup is described by its message, sits inside a modal composer,
  fits narrow screens, and the panel fits a phone.
2026-11-02 20:26:21 +01:00

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. Nothing is sent while a message is set to be signed or encrypted, also when proofreading is required; text written before the user turned that on may already have been checked."
>
<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 &gt; 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 &quot;On, required&quot; and, in the plugin&apos;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>
);
}