feat: let admins require or turn off proofreading per group and plugin #1397

This commit is contained in:
Linus Rath
2026-11-02 19:46:01 +01:00
parent 67549d5f54
commit 5fc65b47cc
10 changed files with 669 additions and 0 deletions
@@ -14,6 +14,7 @@ import { OrgTemplatesPage } from '../_pages/org-templates';
import { OrgSecurityPage } from '../_pages/org-security';
import { OrgSharingPage } from '../_pages/org-sharing';
import { OrgLabelsPage } from '../_pages/org-labels';
import { OrgProofreadingPage } from '../_pages/org-proofreading';
import { OrgCalendarsPage } from '../_pages/org-calendars';
import { OrgNoticesPage } from '../_pages/org-notices';
import { OrgDevicesPage } from '../_pages/org-devices';
@@ -57,6 +58,7 @@ export default function AdminPage() {
case 'org-security': return <OrgSecurityPage />;
case 'org-sharing': return <OrgSharingPage />;
case 'org-labels': return <OrgLabelsPage />;
case 'org-proofreading': return <OrgProofreadingPage />;
case 'org-calendars': return <OrgCalendarsPage />;
case 'org-notices': return <OrgNoticesPage />;
case 'org-devices': return <OrgDevicesPage />;
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
// Organisation > Proofreading: the four states for everyone or a group, the
// per-plugin lock, and the warning (with a shortcut) when proofreading is
// required but no checker plugin is forced on.
const puts: Array<Record<string, unknown>> = [];
const patches: Array<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
let plugins: Array<Record<string, unknown>> = [];
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/plugins') {
if (init?.method === 'PATCH') {
const body = JSON.parse(String(init.body));
patches.push(body);
plugins = plugins.map((p) => (p.id === body.id ? { ...p, ...body } : p));
return new Response('{"ok":true}');
}
return new Response(JSON.stringify(plugins));
}
if (init?.method === 'PUT') {
const body = JSON.parse(String(init.body));
puts.push(body);
stored = body;
return new Response('{"ok":true}');
}
return new Response(JSON.stringify(stored));
}),
withBasePath: (p: string) => p,
getPathPrefix: () => '',
}));
import { OrgProofreadingPage } from '../_pages/org-proofreading';
import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft';
const POLICY = {
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], organisation: {},
scopes: [{ id: 'sales', label: 'Sales', match: { groups: ['sales@example.org'] }, policy: {} }],
};
beforeEach(() => {
puts.length = 0;
patches.length = 0;
stored = JSON.parse(JSON.stringify(POLICY));
plugins = [
{ id: 'languagetool', name: 'Proofreading (LanguageTool)', enabled: true, forceEnabled: false, permissions: ['compose:check', 'http:proxy'] },
{ id: 'gravatar', name: 'Gravatar', enabled: true, permissions: ['http:fetch'] },
];
useOrgScopeStore.setState({ scopeId: EVERYONE });
});
const mainSelect = () => screen.getAllByRole('combobox')[0] as HTMLSelectElement;
const save = async () => {
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(puts).toHaveLength(1));
return puts[0] as { restrictions: Record<string, unknown>; defaults: Record<string, unknown>; scopes: Array<{ id: string; policy: Record<string, Record<string, unknown>> }> };
};
describe('Organisation > Proofreading', () => {
it('lists only checker plugins', async () => {
render(<OrgProofreadingPage />);
expect(await screen.findByText('Proofreading (LanguageTool)')).toBeInTheDocument();
expect(screen.queryByText('Gravatar')).toBeNull();
});
it('requires proofreading for everyone, warns until a checker is forced on, and forces it', async () => {
render(<OrgProofreadingPage />);
await screen.findByText('Proofreading (LanguageTool)');
expect(screen.queryByTestId('proofreading-force-warning')).toBeNull();
fireEvent.change(mainSelect(), { target: { value: 'on-locked' } });
expect(await screen.findByTestId('proofreading-force-warning')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Force Proofreading (LanguageTool) on for everyone' }));
await waitFor(() => expect(patches).toEqual([{ id: 'languagetool', enabled: true, forceEnabled: true }]));
await waitFor(() => expect(screen.queryByTestId('proofreading-force-warning')).toBeNull());
const policy = await save();
expect(policy.restrictions.proofreading).toEqual({ locked: true, value: true });
expect(policy.defaults.proofreading).toBe(true);
});
it('turns it off and hides it for everyone', async () => {
render(<OrgProofreadingPage />);
await screen.findByText('Proofreading (LanguageTool)');
fireEvent.change(mainSelect(), { target: { value: 'off-locked' } });
const policy = await save();
expect(policy.restrictions.proofreading).toEqual({ locked: true, value: false, hidden: true });
});
it('presets it on, without a lock', async () => {
stored = { ...JSON.parse(JSON.stringify(POLICY)), restrictions: { proofreading: { locked: true, value: false, hidden: true } } };
render(<OrgProofreadingPage />);
await screen.findByText('Proofreading (LanguageTool)');
expect(mainSelect().value).toBe('off-locked');
fireEvent.change(mainSelect(), { target: { value: 'on' } });
const policy = await save();
expect(policy.restrictions.proofreading).toBeUndefined();
expect(policy.defaults.proofreading).toBe(true);
});
it('requires it for one group only, and that group can inherit again', async () => {
render(<OrgProofreadingPage />);
await screen.findByText('Proofreading (LanguageTool)');
fireEvent.click(screen.getByRole('button', { name: 'Sales' }));
expect(mainSelect().value).toBe('inherit');
fireEvent.change(mainSelect(), { target: { value: 'on-locked' } });
const policy = await save();
expect(policy.restrictions.proofreading).toBeUndefined();
const sales = policy.scopes.find((s) => s.id === 'sales')!;
expect(sales.policy.restrictions.proofreading).toEqual({ locked: true, value: true });
expect(sales.policy.defaults.proofreading).toBe(true);
});
it('locks one plugin off', async () => {
render(<OrgProofreadingPage />);
await screen.findByText('Proofreading (LanguageTool)');
fireEvent.change(screen.getByRole('combobox', { name: 'Proofreading with Proofreading (LanguageTool)' }), { target: { value: 'off-locked' } });
const policy = await save();
expect(policy.restrictions['proofreading.languagetool']).toEqual({ locked: true, value: false });
});
it('locks the writing language with a value', async () => {
render(<OrgProofreadingPage />);
await screen.findByText('Proofreading (LanguageTool)');
fireEvent.change(screen.getByRole('combobox', { name: 'Writing language' }), { target: { value: 'de-CH' } });
fireEvent.click(screen.getByLabelText('Lock'));
const policy = await save();
expect(policy.restrictions.writingLanguage).toEqual({ locked: true, value: 'de-CH' });
expect(policy.defaults.writingLanguage).toBe('de-CH');
});
});
@@ -0,0 +1,14 @@
'use client';
import { OrgPage } from '@/components/admin/org/org-page';
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
import { ProofreadingSection } from '@/components/admin/org/sections/proofreading-section';
export function OrgProofreadingPage() {
const draft = useOrgPolicyDraft('org-proofreading');
return (
<OrgPage pageId="org-proofreading" draft={draft}>
<ProofreadingSection draft={draft} />
</OrgPage>
);
}
+2
View File
@@ -30,6 +30,7 @@ import {
Store,
SwatchBook,
Tag,
SpellCheck,
X,
type AppIcon,
} from '@/components/icons';
@@ -60,6 +61,7 @@ const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
'org-security': ShieldCheck,
'org-sharing': Share2,
'org-labels': Tag,
'org-proofreading': SpellCheck,
'org-calendars': CalendarDays,
'org-notices': Bell,
'org-devices': Smartphone,
@@ -0,0 +1,233 @@
'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 &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>
);
}
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
import { resolvePolicy, type PolicySubject } from '@/lib/admin/policy-scopes';
import { checkerAccess, proofreadingAccess, type PolicyView } from '@/lib/proofreading/gate';
import {
checkersToForce, pluginProofreadingState, pluginRestrictionFor, proofreadingPolicyFor, proofreadingState,
type ProofreadingState,
} from '@/lib/admin/proofreading-policy';
// The four admin states as the Organisation > Proofreading page writes
// them, for everyone and for one group, resolved the way the client
// resolves the policy for an account, and what a user then gets.
const SALES: PolicySubject = { email: 'anna@example.org', groups: ['sales@example.org'] };
const OTHER: PolicySubject = { email: 'bob@example.org', groups: [] };
function everyone(state: ProofreadingState): SettingsPolicy {
const { restriction, preset } = proofreadingPolicyFor(state);
return {
...DEFAULT_POLICY,
restrictions: restriction ? { proofreading: restriction } : {},
defaults: { proofreading: preset },
};
}
function scoped(state: ProofreadingState, base: SettingsPolicy = DEFAULT_POLICY): SettingsPolicy {
const { restriction, preset } = proofreadingPolicyFor(state);
return {
...base,
scopes: [{
id: 'sales', label: 'Sales', match: { groups: ['sales@example.org'] },
policy: { restrictions: { proofreading: restriction ?? {} }, defaults: { proofreading: preset } },
}],
};
}
function viewFor(policy: SettingsPolicy, subject: PolicySubject): PolicyView {
const resolved = resolvePolicy(policy, subject).policy;
return { restrictions: resolved.restrictions, defaults: resolved.defaults as Record<string, unknown>, settled: true };
}
/** A user who never touched the setting starts at the policy default. */
const freshUser = (view: PolicyView) => ({ proofreading: view.defaults.proofreading === true, proofreadingPlugins: {} });
describe('the four states for everyone', () => {
it('off and locked: nothing runs and the setting is hidden, whatever the user stored', () => {
const view = viewFor(everyone('off-locked'), OTHER);
expect(proofreadingAccess(view, { proofreading: true, proofreadingPlugins: {} })).toMatchObject({ on: false, lockedOff: true, hidden: true });
expect(checkerAccess(view, { proofreading: true, proofreadingPlugins: { lt: true } }, 'lt').on).toBe(false);
});
it('off by default: off until the user turns it on', () => {
const view = viewFor(everyone('off'), OTHER);
expect(proofreadingAccess(view, freshUser(view))).toMatchObject({ on: false, forced: false, lockedOff: false });
expect(checkerAccess(view, { proofreading: true, proofreadingPlugins: {} }, 'lt').on).toBe(true);
});
it('on by default: on until the user turns it off', () => {
const view = viewFor(everyone('on'), OTHER);
expect(proofreadingAccess(view, freshUser(view))).toMatchObject({ on: true, forced: false });
expect(checkerAccess(view, { proofreading: false, proofreadingPlugins: {} }, 'lt').on).toBe(false);
});
it('on and locked: on for every checker, also over a stored "off"', () => {
const view = viewFor(everyone('on-locked'), OTHER);
expect(proofreadingAccess(view, { proofreading: false, proofreadingPlugins: {} })).toMatchObject({ on: true, forced: true });
expect(checkerAccess(view, { proofreading: false, proofreadingPlugins: { lt: false } }, 'lt')).toMatchObject({ on: true, forced: true });
});
});
describe('the four states for one group', () => {
it('apply to its members and leave everyone else alone', () => {
for (const state of ['off-locked', 'on', 'on-locked'] as const) {
const policy = scoped(state);
const member = viewFor(policy, SALES);
const other = viewFor(policy, OTHER);
expect(proofreadingState(member.restrictions.proofreading, member.defaults.proofreading)).toBe(state);
expect(proofreadingState(other.restrictions.proofreading, other.defaults.proofreading)).toBe('off');
}
});
it('a group can lift a lock everyone has', () => {
const policy = scoped('on', everyone('off-locked'));
const member = viewFor(policy, SALES);
expect(proofreadingAccess(member, freshUser(member))).toMatchObject({ on: true, lockedOff: false });
expect(proofreadingAccess(viewFor(policy, OTHER), { proofreading: true, proofreadingPlugins: {} })).toMatchObject({ lockedOff: true });
});
it('a group can require it while everyone else chooses', () => {
const policy = scoped('on-locked', everyone('off'));
expect(checkerAccess(viewFor(policy, SALES), { proofreading: false, proofreadingPlugins: {} }, 'lt').forced).toBe(true);
expect(checkerAccess(viewFor(policy, OTHER), { proofreading: false, proofreadingPlugins: {} }, 'lt').on).toBe(false);
});
});
describe('per plugin', () => {
it('round-trips the plugin states', () => {
for (const s of ['follow', 'off-locked', 'on-locked'] as const) expect(pluginProofreadingState(pluginRestrictionFor(s))).toBe(s);
});
});
describe('checkersToForce', () => {
const lt = { id: 'lt', enabled: true };
const ai = { id: 'ai', enabled: true };
it('asks for nothing unless proofreading is required', () => {
expect(checkersToForce('on', {}, [lt, ai])).toEqual([]);
expect(checkersToForce('off-locked', { lt: 'on-locked' }, [lt])).toEqual([]);
});
it('offers the checkers when required and none is forced, and stops once one is', () => {
expect(checkersToForce('on-locked', {}, [lt, ai])).toEqual(['lt', 'ai']);
expect(checkersToForce('on-locked', {}, [{ ...lt, forceEnabled: true }, ai])).toEqual([]);
});
it('asks for a plugin required on its own', () => {
expect(checkersToForce('off', { lt: 'on-locked' }, [lt, ai])).toEqual(['lt']);
expect(checkersToForce('off', { lt: 'on-locked' }, [{ ...lt, forceEnabled: true }])).toEqual([]);
});
});
+11
View File
@@ -23,6 +23,7 @@ export const ADMIN_PAGE_IDS = [
'org-security',
'org-sharing',
'org-labels',
'org-proofreading',
'org-calendars',
'org-notices',
'org-devices',
@@ -200,6 +201,16 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
{ id: 'labels', title: 'Classification labels', keywords: 'labels presets default required header prefix colour' },
],
},
{
id: 'org-proofreading',
label: 'Proofreading',
description: 'Spelling and grammar checks while users write: required, off, or their choice',
group: 'organisation',
keywords: 'proofreading spelling grammar spell check languagetool writing language mandatory required',
sections: [
{ id: 'proofreading', title: 'Proofreading', keywords: 'checker plugin required lock language force' },
],
},
{
id: 'org-calendars',
label: 'Calendars',
+78
View File
@@ -0,0 +1,78 @@
// The admin's choice for proofreading, as the four states the Organisation >
// Proofreading page offers, and how each is written to the settings policy
// (lib/proofreading/gate.ts reads it back on the client):
//
// off-locked restriction { locked, value: false, hidden } no checker runs, the setting is hidden
// off default false users turn it on
// on default true users may turn it off
// on-locked restriction { locked, value: true } required, users can't turn it off
//
// Per checker plugin (`proofreading.<pluginId>`): follow the setting above,
// off and locked, or on and required.
import type { SettingRestriction } from './types';
export type ProofreadingState = 'off-locked' | 'off' | 'on' | 'on-locked';
export type PluginProofreadingState = 'follow' | 'off-locked' | 'on-locked';
export const PROOFREADING_STATES: readonly { value: ProofreadingState; label: string }[] = [
{ value: 'off-locked', label: 'Off, locked (hidden for users)' },
{ value: 'off', label: 'Off by default, users can turn it on' },
{ value: 'on', label: 'On by default, users can turn it off' },
{ value: 'on-locked', label: 'On, required' },
];
export const PLUGIN_STATES: readonly { value: PluginProofreadingState; label: string }[] = [
{ value: 'follow', label: 'Follow the setting above' },
{ value: 'off-locked', label: 'Off, locked' },
{ value: 'on-locked', label: 'On, required' },
];
/** The state a restriction and default stand for. */
export function proofreadingState(restriction: SettingRestriction | undefined, preset: unknown): ProofreadingState {
if (restriction?.locked) return restriction.value === true ? 'on-locked' : 'off-locked';
return preset === true ? 'on' : 'off';
}
/** What to store for a state: the restriction and the default. */
export function proofreadingPolicyFor(state: ProofreadingState): { restriction: SettingRestriction | undefined; preset: boolean } {
switch (state) {
case 'off-locked': return { restriction: { locked: true, value: false, hidden: true }, preset: false };
case 'off': return { restriction: undefined, preset: false };
case 'on': return { restriction: undefined, preset: true };
case 'on-locked': return { restriction: { locked: true, value: true }, preset: true };
}
}
export function pluginProofreadingState(restriction: SettingRestriction | undefined): PluginProofreadingState {
if (!restriction?.locked) return 'follow';
return restriction.value === true ? 'on-locked' : 'off-locked';
}
export function pluginRestrictionFor(state: PluginProofreadingState): SettingRestriction | undefined {
if (state === 'follow') return undefined;
return { locked: true, value: state === 'on-locked' };
}
/**
* Whether proofreading is required somewhere without a checker plugin the
* admin forced on: users could then turn the plugin off in Add-ons, and with
* it the check. Returns the checker plugins to offer forcing on.
*/
export function checkersToForce(
main: ProofreadingState,
pluginStates: Record<string, PluginProofreadingState>,
checkers: readonly { id: string; enabled: boolean; forceEnabled?: boolean }[],
): string[] {
const forced = (id: string) => checkers.some((c) => c.id === id && c.enabled && c.forceEnabled);
const needed = new Set<string>();
for (const [id, state] of Object.entries(pluginStates)) {
if (state === 'on-locked' && main !== 'off-locked' && !forced(id)) needed.add(id);
}
// The setting required as a whole: one checker forced on is enough.
const candidates = checkers.filter((c) => (pluginStates[c.id] ?? 'follow') === 'follow');
if (main === 'on-locked' && !candidates.some((c) => forced(c.id))) {
for (const c of candidates) needed.add(c.id);
}
return [...needed];
}
+3
View File
@@ -84,6 +84,9 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean', page: 'policy', defaultValue: false },
{ key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'], page: 'policy', defaultValue: 'domain' },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean', page: 'policy', defaultValue: false },
// Set on Organisation > Proofreading, with per-plugin keys `proofreading.<pluginId>` (lib/proofreading/gate.ts).
{ key: 'proofreading', label: 'Proofreading', description: 'Locked off, no proofreading plugin gets any text; locked on, it checks in every composer.', category: 'Composer', type: 'boolean', page: 'org-proofreading' },
{ key: 'writingLanguage', label: 'Writing language', category: 'Composer', type: 'enum', page: 'org-proofreading' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean', page: 'policy', defaultValue: true },
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean', page: 'policy', defaultValue: true },
{ key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean', page: 'policy', defaultValue: false },
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiFetch = vi.hoisted(() => vi.fn());
vi.mock('@/lib/browser-navigation', () => ({ apiFetch }));
import { useSettingsStore } from '../settings-store';
import { usePolicyStore } from '../policy-store';
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
import { checkerAccess } from '@/lib/proofreading/gate';
import { currentPolicyView } from '@/components/email/proofreading/use-proofreading';
// Proofreading's settings: synced like the others, cleaned when they come in,
// and an admin lock wins over an old synced value or one set from the console.
const settings = () => useSettingsStore.getState();
const loadPolicy = (patch: Partial<SettingsPolicy>) =>
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, ...patch }, loaded: true, partial: false, subjectPending: false });
beforeEach(() => {
localStorage.clear();
apiFetch.mockReset();
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, loaded: false });
settings().disableSync();
settings().resetToDefaults();
});
describe('proofreading settings', () => {
it('starts off, with automatic language detection', () => {
expect(settings().proofreading).toBe(false);
expect(settings().writingLanguage).toBe('auto');
expect(settings().proofreadingPlugins).toEqual({});
});
it('drops odd values from an import', () => {
settings().importSettings(JSON.stringify({
proofreading: true,
proofreadingPlugins: { lt: true, ai: 'yes', __proto__: { x: true } },
writingLanguage: '<script>',
}));
expect(settings().proofreading).toBe(true);
expect(settings().proofreadingPlugins).toEqual({ lt: true });
expect(settings().writingLanguage).toBe('auto');
settings().importSettings(JSON.stringify({ writingLanguage: 'de-CH' }));
expect(settings().writingLanguage).toBe('de-CH');
});
it('a lock on puts the setting back after an old synced "off", and the checker stays on', () => {
loadPolicy({ restrictions: { proofreading: { locked: true, value: true } } });
expect(settings().proofreading).toBe(true);
settings().importSettings(JSON.stringify({ proofreading: false, proofreadingPlugins: { lt: false } }));
expect(settings().proofreading).toBe(true);
// Even a value forced from the console doesn't turn the checker off.
useSettingsStore.setState({ proofreading: false, proofreadingPlugins: { lt: false } });
expect(checkerAccess(currentPolicyView(), settings(), 'lt')).toMatchObject({ on: true, forced: true });
});
it('a lock off keeps every checker off, whatever is stored', () => {
loadPolicy({ restrictions: { proofreading: { locked: true, value: false, hidden: true } } });
useSettingsStore.setState({ proofreading: true, proofreadingPlugins: { lt: true } });
expect(checkerAccess(currentPolicyView(), settings(), 'lt').on).toBe(false);
});
it('checks nothing until the policy has loaded', () => {
useSettingsStore.setState({ proofreading: true });
usePolicyStore.setState({ loaded: false });
expect(checkerAccess(currentPolicyView(), settings(), 'lt').on).toBe(false);
usePolicyStore.setState({ loaded: true, partial: true });
expect(checkerAccess(currentPolicyView(), settings(), 'lt').on).toBe(false);
usePolicyStore.setState({ partial: false, subjectPending: true });
expect(checkerAccess(currentPolicyView(), settings(), 'lt').on).toBe(false);
usePolicyStore.setState({ subjectPending: false });
expect(checkerAccess(currentPolicyView(), settings(), 'lt').on).toBe(true);
});
});