+ These settings make copying harder in Bulwark. They don't stop screenshots, other mail apps, or someone who + knows the browser's tools: the browser fetches mail from the mail server directly, so only the push text and + the office editor are checked on the server.{' '} + + What each switch covers + +
++ With several accounts signed in, each message follows its own account's settings. What isn't about one + message (the install prompt, Ctrl+P, the Files app) follows the strictest account. Lite has no push and no office + editor, so there everything is checked in the browser. +
+x
', 'ltr', 'alice@example.org · 1 Nov 2026')).toContain('alice@example.org · 1 Nov 2026'); + expect(printDocument('T', 'x
', 'ltr')).not.toContain('bulwark-watermark"'); + }); +}); diff --git a/components/device/device-policy-host.tsx b/components/device/device-policy-host.tsx new file mode 100644 index 000000000..d7465be08 --- /dev/null +++ b/components/device/device-policy-host.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslations } from 'next-intl'; +import { useDevicePolicy, useStrictestDevices } from '@/hooks/use-device-policy'; +import { DOWNLOAD_BLOCKED_EVENT, type DownloadKind } from '@/lib/download-guard'; +// Installs the download guard (lib/download-guard.ts) for every page. +import '@/lib/device-policy'; +import { useMailTransferStore } from '@/stores/mail-transfer-store'; +import { toast } from '@/stores/toast-store'; + +/** Set on while printing is off; app/print.css then prints only the note. */ +export const NO_PRINT_ATTRIBUTE = 'data-no-print'; + +/** + * The page-wide parts of the device policies (Organisation > Devices): + * - says why nothing happened when a download or export was refused; + * - Ctrl+P and the browser's Print menu print a page that only says + * printing is turned off (they can't be stopped, only blanked); + * - an export that runs or waits for its download is stopped and its file + * deleted when "No export" is switched on while it runs. + */ +export function DevicePolicyHost() { + const t = useTranslations('device_policy'); + const strictest = useStrictestDevices(); + const activeNoExport = useDevicePolicy().noExport; + + useEffect(() => { + const onBlocked = (event: Event) => { + const kind = (event as CustomEvent<{ kind?: DownloadKind }>).detail?.kind; + toast.info(kind === 'export' ? t('export_blocked') : t('download_blocked')); + }; + window.addEventListener(DOWNLOAD_BLOCKED_EVENT, onBlocked); + return () => window.removeEventListener(DOWNLOAD_BLOCKED_EVENT, onBlocked); + }, [t]); + + useEffect(() => { + const root = document.documentElement; + if (strictest.noPrint) root.setAttribute(NO_PRINT_ATTRIBUTE, ''); + else root.removeAttribute(NO_PRINT_ATTRIBUTE); + return () => root.removeAttribute(NO_PRINT_ATTRIBUTE); + }, [strictest.noPrint]); + + useEffect(() => { + if (!activeNoExport) return; + const store = useMailTransferStore.getState(); + if (store.job?.kind !== 'export') return; + const job = store.job; + store.cancelJob('policy'); + // The private file goes at once, once the job has stopped: nothing may download it any more. + const discard = () => { + if (useMailTransferStore.getState().job?.controller === job.controller) useMailTransferStore.getState().close({ now: true }); + }; + if (job.finished) void job.finished.then(discard, discard); + else discard(); + }, [activeNoExport]); + + if (!strictest.noPrint || typeof document === 'undefined') return null; + return createPortal( + , + document.body, + ); +} diff --git a/components/device/watermark.tsx b/components/device/watermark.tsx new file mode 100644 index 000000000..c58904b7d --- /dev/null +++ b/components/device/watermark.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useMemo } from 'react'; +import { useDevicePolicy, useStrictestDevices } from '@/hooks/use-device-policy'; +import { watermarkAddress, watermarkText, type DeviceSubject } from '@/lib/device-policy'; +import { WATERMARK_COLUMNS, WATERMARK_CSS, WATERMARK_ROWS } from '@/lib/watermark'; +import { useAuthStore } from '@/stores/auth-store'; +import { useLocaleStore } from '@/stores/locale-store'; + +type HookSubject = Exclude