diff --git a/app/(main)/admin/[[...segments]]/page.tsx b/app/(main)/admin/[[...segments]]/page.tsx index 2a271e2c3..6a23552a7 100644 --- a/app/(main)/admin/[[...segments]]/page.tsx +++ b/app/(main)/admin/[[...segments]]/page.tsx @@ -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 ; case 'org-sharing': return ; case 'org-labels': return ; + case 'org-proofreading': return ; case 'org-calendars': return ; case 'org-notices': return ; case 'org-devices': return ; diff --git a/app/(main)/admin/__tests__/org-proofreading-page.test.tsx b/app/(main)/admin/__tests__/org-proofreading-page.test.tsx new file mode 100644 index 000000000..4463a1f6d --- /dev/null +++ b/app/(main)/admin/__tests__/org-proofreading-page.test.tsx @@ -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> = []; +const patches: Array> = []; +let stored: Record = {}; +let plugins: Array> = []; +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; defaults: Record; scopes: Array<{ id: string; policy: Record> }> }; +}; + +describe('Organisation > Proofreading', () => { + it('lists only checker plugins', async () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/app/(main)/admin/_pages/org-proofreading.tsx b/app/(main)/admin/_pages/org-proofreading.tsx new file mode 100644 index 000000000..8e37f5eec --- /dev/null +++ b/app/(main)/admin/_pages/org-proofreading.tsx @@ -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 ( + + + + ); +} diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index d2003ba8a..6a7dcb513 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -30,6 +30,7 @@ import { Store, SwatchBook, Tag, + SpellCheck, X, type AppIcon, } from '@/components/icons'; @@ -60,6 +61,7 @@ const PAGE_ICONS: Record = { 'org-security': ShieldCheck, 'org-sharing': Share2, 'org-labels': Tag, + 'org-proofreading': SpellCheck, 'org-calendars': CalendarDays, 'org-notices': Bell, 'org-devices': Smartphone, diff --git a/components/admin/org/sections/proofreading-section.tsx b/components/admin/org/sections/proofreading-section.tsx new file mode 100644 index 000000000..bd20d26d1 --- /dev/null +++ b/components/admin/org/sections/proofreading-section.tsx @@ -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(null); + const [forcing, setForcing] = useState(null); + const [forceError, setForceError] = useState(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 = {}; + 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 ( + + +
+ 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}`} + /> +
+
+ ); + })} + + {toForce.length > 0 && ( +
+

+

+
+ {toForce.map((id) => { + const p = plugins!.find((x) => x.id === id)!; + return ( + + ); + })} +
+ {forceError &&

{forceError}

} +
+ )} + + +
+
+ writeLanguage(languageValue ?? '', e.target.checked)} + /> + Lock + +
+
+
+ +

+ 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. +

+
+ ); +} diff --git a/lib/__tests__/proofreading-admin-policy.test.ts b/lib/__tests__/proofreading-admin-policy.test.ts new file mode 100644 index 000000000..36890baf2 --- /dev/null +++ b/lib/__tests__/proofreading-admin-policy.test.ts @@ -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, 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([]); + }); +}); diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index 4a93f4f09..c0d5edf9a 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -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', diff --git a/lib/admin/proofreading-policy.ts b/lib/admin/proofreading-policy.ts new file mode 100644 index 000000000..e7335c236 --- /dev/null +++ b/lib/admin/proofreading-policy.ts @@ -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.`): 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, + 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(); + 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]; +} diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts index 3b6c75525..32ccb4119 100644 --- a/lib/admin/restriction-keys.ts +++ b/lib/admin/restriction-keys.ts @@ -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.` (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 }, diff --git a/stores/__tests__/settings-store-proofreading.test.ts b/stores/__tests__/settings-store-proofreading.test.ts new file mode 100644 index 000000000..1eea474c8 --- /dev/null +++ b/stores/__tests__/settings-store-proofreading.test.ts @@ -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) => + 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: '