diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 31c983c1d..59481960e 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -10,6 +10,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; +import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm"; import { CommandPaletteHost } from "@/components/command-palette/command-palette"; import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; @@ -65,6 +66,7 @@ export default async function LocaleLayout({ {children} + diff --git a/components/admin/org/__tests__/external-mail-section.test.tsx b/components/admin/org/__tests__/external-mail-section.test.tsx new file mode 100644 index 000000000..29a1ff1b3 --- /dev/null +++ b/components/admin/org/__tests__/external-mail-section.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Security > External mail: the switches and the domain list +// go into the draft at the chosen scope, and the External Mail Warning +// plugin's safe domains can be copied in as an unsaved change. + +const puts: Array> = []; +let stored: Record = {}; +let plugins: Array> = []; +let pluginConfig: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (url: string, init?: RequestInit) => { + if (url === '/api/admin/plugins') return new Response(JSON.stringify(plugins)); + if (url === '/api/admin/plugins/external-mail-warning/config') return new Response(JSON.stringify(pluginConfig)); + 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 { ExternalMailSection } from '../sections/external-mail-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { externalMail: { domains: ['partner.com'] } }, + scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +async function renderSection() { + render(); + await waitFor(() => expect(screen.getByTestId('org-external-sender-label')).toBeInTheDocument()); +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + plugins = []; + pluginConfig = {}; + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('External mail section', () => { + it('starts with both switches off and saves what the admin turns on', async () => { + await renderSection(); + expect(screen.getByTestId('org-external-sender-label').getAttribute('aria-checked')).toBe('false'); + expect(screen.getByTestId('org-external-recipient-warning').getAttribute('aria-checked')).toBe('false'); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + fireEvent.click(screen.getByTestId('org-external-recipient-warning')); + await act(async () => { await latest!.save(); }); + expect((puts[0].organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, domains: ['partner.com'] }); + }); + + it('writes a scope override only into that scope', async () => { + await renderSection(); + act(() => latest!.setScopeId('two')); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ externalMail: { senderLabel: true } }); + expect(latest!.policy?.organisation?.externalMail.senderLabel).toBe(false); + }); + + it('takes the typed domain list apart when the field is left', async () => { + await renderSection(); + const field = screen.getByTestId('org-external-domains'); + fireEvent.change(field, { target: { value: 'partner.com, Sister.example\n@third.test' } }); + fireEvent.blur(field); + expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'sister.example', 'third.test']); + expect(screen.queryByTestId('org-external-domains-rejected')).toBeNull(); + }); + + it('stores an IDN in ASCII, shows it in Unicode and names entries that are not domains', async () => { + await renderSection(); + const field = screen.getByTestId('org-external-domains') as HTMLTextAreaElement; + fireEvent.change(field, { target: { value: 'bücher.de\nnot_a_domain, x' } }); + fireEvent.blur(field); + expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['xn--bcher-kva.de']); + expect(screen.getByTestId('org-external-domains-rejected').textContent).toContain('not_a_domain, x'); + await waitFor(() => expect(field.value).toBe('bücher.de'), { timeout: 5000 }); + }); + + it('offers no import and no note without the plugin', async () => { + await renderSection(); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + expect(screen.queryByTestId('org-external-import-plugin')).toBeNull(); + expect(screen.queryByTestId('org-external-plugin-note')).toBeNull(); + }); + + it('asks to update an old plugin while the warnings are on and imports its safe domains as a draft change', async () => { + plugins = [{ id: 'external-mail-warning', version: '1.5.1' }]; + pluginConfig = { safeDomains: 'partner.com, acme.example\nsub.acme.example' }; + await renderSection(); + await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument()); + expect(screen.queryByTestId('org-external-plugin-note')).toBeNull(); + + fireEvent.click(screen.getByTestId('org-external-recipient-warning')); + expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('Update or remove the External Mail Warning plugin'); + + await act(async () => { fireEvent.click(screen.getByTestId('org-external-import-plugin')); }); + await waitFor(() => expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'acme.example', 'sub.acme.example'])); + expect(latest!.dirty).toBe(true); + expect(puts).toHaveLength(0); + }); + + it('only suggests removing a plugin that steps back by itself', async () => { + plugins = [{ id: 'external-mail-warning', version: '1.6.0' }]; + await renderSection(); + await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('You can remove the External Mail Warning plugin'); + }); +}); diff --git a/components/admin/org/sections/external-mail-section.tsx b/components/admin/org/sections/external-mail-section.tsx index 44c7a8a5a..e77c38efc 100644 --- a/components/admin/org/sections/external-mail-section.tsx +++ b/components/admin/org/sections/external-mail-section.tsx @@ -1,14 +1,184 @@ 'use client'; -import { SettingsSection } from '@/components/settings/settings-section'; +import { useEffect, useState } from 'react'; +import { Download, Info } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section'; +import { AdminLink } from '@/components/admin/admin-link'; +import { parseDomainList } from '@/lib/admin/organisation-policy'; +import { toUnicodeDomain } from '@/lib/idn'; +import { apiFetch } from '@/lib/browser-navigation'; +import { EXTERNAL_MAIL_WARNING_PLUGIN_ID } from '@/lib/plugin-sandbox/host-features'; +import { InheritNote } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; -/** Organisation feature 2; filled in by its own step (local-data/org-features-plan.md). */ +/** The first release of the plugin core took over from that steps back on its own. */ +export const PLUGIN_STEPS_BACK_FROM = '1.6.0'; + +function older(version: string, than: string): boolean { + const a = version.split(/[-+]/)[0].split('.').map(Number); + const b = than.split(/[-+]/)[0].split('.').map(Number); + for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0); + return false; +} + +type InstalledPlugin = { version: string }; + +/** The External Mail Warning plugin when the admin installed it, else null. */ +function useInstalledPlugin(): InstalledPlugin | null { + const [plugin, setPlugin] = useState(null); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await apiFetch('/api/admin/plugins'); + const list = res.ok ? ((await res.json()) as Array<{ id: string; version?: string }>) : []; + const found = Array.isArray(list) ? list.find((p) => p.id === EXTERNAL_MAIL_WARNING_PLUGIN_ID) : undefined; + if (!cancelled) setPlugin(found ? { version: found.version ?? '' } : null); + } catch { + if (!cancelled) setPlugin(null); + } + })(); + return () => { cancelled = true; }; + }, []); + return plugin; +} + +const onOff = (v: boolean) => (v ? 'on' : 'off'); + +/** + * Organisation > Security > External mail: the "External" sender label, + * the confirm before sending outside, which domains are internal, and the + * hand-over from the External Mail Warning plugin. + */ export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; + const senderLabel = draft.orgValue('externalMail', 'senderLabel'); + const recipientWarning = draft.orgValue('externalMail', 'recipientWarning'); + const domains = draft.orgValue('externalMail', 'domains'); + const trustContacts = draft.orgValue('externalMail', 'trustContacts'); + const plugin = useInstalledPlugin(); + const anyOn = senderLabel.value || recipientWarning.value; + + // The list is typed as text and taken apart when the field is left, so a + // half-typed domain isn't thrown away on every key. It is stored in ASCII + // (xn--) form and shown in Unicode; entries that aren't domains are named. + const stored = domains.value.join('\n'); + const joined = domains.value.map(toUnicodeDomain).join('\n'); + const [text, setText] = useState(joined); + const [rejected, setRejected] = useState([]); + useEffect(() => { setText(joined); }, [joined]); + const commitDomains = (raw: string) => { + const parsed = parseDomainList(raw) ?? { domains: [], rejected: [] }; + setRejected(parsed.rejected); + if (parsed.domains.join('\n') !== stored) draft.setOrgValue('externalMail', 'domains', parsed.domains); + else setText(joined); + }; + + const [importing, setImporting] = useState(false); + const importFromPlugin = async () => { + setImporting(true); + try { + const res = await apiFetch(`/api/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}/config`); + if (!res.ok) throw new Error(String(res.status)); + const config = (await res.json()) as Record; + const parsed = parseDomainList(config.safeDomains) ?? { domains: [], rejected: [] }; + const fromPlugin = parsed.domains; + setRejected(parsed.rejected); + const added = fromPlugin.filter((d) => !domains.value.includes(d)); + if (added.length > 0) draft.setOrgValue('externalMail', 'domains', [...domains.value, ...added]); + draft.setMessage({ + type: 'success', + text: added.length > 0 + ? `Added ${added.length} ${added.length === 1 ? 'domain' : 'domains'} from the plugin. Save to apply them.` + : fromPlugin.length > 0 ? 'The list already has every domain the plugin had.' : 'The plugin has no safe domains set.', + }); + } catch { + draft.setMessage({ type: 'error', text: 'Could not read the plugin settings' }); + } finally { + setImporting(false); + } + }; + + const outdated = !!plugin && older(plugin.version, PLUGIN_STEPS_BACK_FROM); + return ( - -

Nothing to set here yet.

+ + +
+ draft.setOrgValue('externalMail', 'senderLabel', v)} testId="org-external-sender-label" /> + draft.clearOrgValue('externalMail', 'senderLabel')} /> +
+
+ + +
+ draft.setOrgValue('externalMail', 'recipientWarning', v)} testId="org-external-recipient-warning" /> + draft.clearOrgValue('externalMail', 'recipientWarning')} /> +
+
+ + +
+ draft.setOrgValue('externalMail', 'trustContacts', v)} testId="org-external-trust-contacts" /> + draft.clearOrgValue('externalMail', 'trustContacts')} /> +
+
+ +
+ +

+ Your users' own domains always count as internal. Add partner or sister-company domains here, one per line or separated by commas. A domain covers its subdomains: example.com also covers eu.example.com. +

+