mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
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.
94 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|