Files
webmail/components/settings/proofreading-settings.tsx
T
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

94 lines
4.2 KiB
TypeScript

"use client";
// Settings > Composing > Proofreading: whether checker plugins may check
// what the user writes (overall and per plugin), and the writing language.
// The admin can preset or lock both (settings policy); locked rows show
// "Set by your organisation", a hidden setting isn't shown.
import { useMemo, useSyncExternalStore } from "react";
import { useLocale, useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { allCheckers, subscribeCheckers } from "@/lib/plugin-sandbox/checkers";
import { checkerAccess, proofreadingAccess, PROOFREADING_KEY, type PolicyView } from "@/lib/proofreading/gate";
import { writingLanguageOptions } from "@/lib/proofreading/languages";
import { policySettled } from "@/components/email/proofreading/use-proofreading";
import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section";
export function ProofreadingSettings() {
const t = useTranslations("proofreading.settings");
const locale = useLocale();
const checkers = useSyncExternalStore(subscribeCheckers, allCheckers, allCheckers);
const proofreading = useSettingsStore((s) => s.proofreading);
const proofreadingPlugins = useSettingsStore((s) => s.proofreadingPlugins);
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const policy = usePolicyStore((s) => s.policy);
const settled = usePolicyStore(policySettled);
const view: PolicyView = { restrictions: policy.restrictions ?? {}, defaults: (policy.defaults ?? {}) as Record<string, unknown>, settled };
const user = { proofreading, proofreadingPlugins };
const main = proofreadingAccess(view, user);
const languageLocked = policy.restrictions?.writingLanguage?.locked === true;
const languageHidden = policy.restrictions?.writingLanguage?.hidden === true;
// One row per checker plugin, even if it registered several checkers.
const plugins = useMemo(() => {
const seen = new Map<string, { pluginId: string; label: string; languages: string[] }>();
for (const c of checkers) {
const entry = seen.get(c.pluginId) ?? { pluginId: c.pluginId, label: c.pluginName, languages: [] };
entry.languages.push(...c.languages);
seen.set(c.pluginId, entry);
}
return [...seen.values()];
}, [checkers]);
const languages = useMemo(
() => writingLanguageOptions(locale, plugins.flatMap((p) => p.languages).filter((l) => l.includes("-"))),
[locale, plugins],
);
if (main.lockedOff && main.hidden && languageHidden) return null;
return (
<SettingsSection id="proofreading" title={t("title")} description={t("description")}>
{!(main.lockedOff && main.hidden) && (
<SettingItem label={t("enable")} description={plugins.length ? t("enable_description") : t("no_checker")} locked={main.forced || main.lockedOff}>
<ToggleSwitch
checked={main.forced || (!main.lockedOff && proofreading)}
onChange={(checked) => updateSetting(PROOFREADING_KEY, checked)}
testId="settings-proofreading"
/>
</SettingItem>
)}
{!main.lockedOff && plugins.length > 1 && plugins.map((p) => {
const access = checkerAccess(view, user, p.pluginId);
return (
<SettingItem
key={p.pluginId}
nested
label={p.label}
description={t("plugin_description", { plugin: p.label })}
locked={access.forced || access.lockedOff}
>
<ToggleSwitch
checked={access.on}
onChange={(checked) => updateSetting("proofreadingPlugins", { ...proofreadingPlugins, [p.pluginId]: checked })}
testId={`settings-proofreading-${p.pluginId}`}
/>
</SettingItem>
);
})}
{!languageHidden && (
<SettingItem label={t("writing_language")} description={t("writing_language_description")} locked={languageLocked}>
<Select
value={writingLanguage}
onChange={(value) => updateSetting("writingLanguage", value)}
options={[{ value: "auto", label: t("auto") }, ...languages]}
/>
</SettingItem>
)}
</SettingsSection>
);
}