mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
feat: let admins require or turn off proofreading per group and plugin #1397
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 > 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>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user