diff --git a/app/(main)/admin/__tests__/policy-features-scope.test.tsx b/app/(main)/admin/__tests__/policy-features-scope.test.tsx new file mode 100644 index 000000000..f900cc4df --- /dev/null +++ b/app/(main)/admin/__tests__/policy-features-scope.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; +import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft'; +import { ADMIN_GROUP_LABELS } from '@/components/admin/admin-page-icons'; +import { adminPageDef } from '@/lib/admin/admin-nav'; + +// Usability 2026-11-04, settings-22: features, locked settings and defaults +// sit with the organisation pages under "Users", and features take the same +// "Applies to" picker as the rest. + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + puts.push(JSON.parse(String(init.body))); + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored), { headers: { 'content-type': 'application/json' } }); + }), + withBasePath: (p: string) => p, +})); +vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children })); + +import { PolicyPage } from '../_pages/policy'; + +const BASE = { + restrictions: {}, features: { calendarEnabled: true, accountArchiveEnabled: false }, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + pushRelays: [], pushRelayUrl: '', pushRelayUrlLocked: false, defaultSidebarApps: [], + scopes: [{ id: 'sales', label: 'Sales', match: { domains: ['sales.example'] }, policy: {} }], +}; + +const features = () => document.querySelector('[data-settings-section="features"]')!; +function rowSwitch(label: string): HTMLElement { + let el: HTMLElement | null = within(features()).getByText(label, { selector: 'label' }); + while (el && !el.hasAttribute('data-search-label')) el = el.parentElement; + return within(el!).getByRole('switch'); +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(BASE)); + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); +afterEach(() => { + useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null }); +}); + +describe('Features and settings', () => { + it('sits in the Users group with the organisation pages', () => { + const page = adminPageDef('policy'); + expect(page.label).toBe('Features and settings'); + expect(page.group).toBe('organisation'); + expect(adminPageDef('organisation').group).toBe('organisation'); + expect(ADMIN_GROUP_LABELS.organisation).toBe('Users'); + }); + + it('turns a feature off for one group only', async () => { + render(); + await waitFor(() => expect(features()).toBeTruthy()); + const picker = within(features()).getByRole('group', { name: 'Applies to' }); + fireEvent.click(within(picker).getByRole('button', { name: 'Sales' })); + + const calendar = rowSwitch('Calendar'); + expect(calendar.getAttribute('aria-checked')).toBe('true'); + expect(within(features()).getAllByText('From Everyone: On').length).toBeGreaterThan(0); + fireEvent.click(calendar); + + fireEvent.click(screen.getByRole('button', { name: 'Save policy' })); + await waitFor(() => expect(puts).toHaveLength(1)); + const saved = puts[0] as { features: Record; scopes: Array<{ id: string; policy: { features?: Record } }> }; + expect(saved.features.calendarEnabled).toBe(true); + expect(saved.scopes[0].policy.features).toEqual({ calendarEnabled: false }); + }); + + it('keeps features the server checks itself for everyone', async () => { + render(); + await waitFor(() => expect(features()).toBeTruthy()); + fireEvent.click(within(within(features()).getByRole('group', { name: 'Applies to' })).getByRole('button', { name: 'Sales' })); + expect(rowSwitch('Account list backup')).toBeDisabled(); + }); + + it('uses sentence case and plain words for the features', async () => { + render(); + await waitFor(() => expect(features()).toBeTruthy()); + expect(within(features()).queryByText('Settings Export/Import')).toBeNull(); + expect(within(features()).getByText('Settings backup')).toBeTruthy(); + expect(within(features()).getByText('Hover buttons')).toBeTruthy(); + }); +}); diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx index e09b862bc..e937851c0 100644 --- a/app/(main)/admin/_pages/policy.tsx +++ b/app/(main)/admin/_pages/policy.tsx @@ -25,37 +25,39 @@ import { AdminSaveBar } from '@/components/admin/save-bar'; import { Badge } from '@/components/admin/badge'; import { useAdminFeedback } from '@/components/admin/feedback'; import { inputClass } from '@/components/admin/config-items'; -import { LockedSettingsSection, PolicyDefaultsSection } from '@/components/admin/policy/user-settings-policy'; +import { InheritLine, LockedSettingsSection, PolicyDefaultsSection, ScopePicker, useScopeId } from '@/components/admin/policy/user-settings-policy'; +import { EVERYONE_SCOPE, featureAt, withFeatureAt } from '@/lib/admin/policy-settings'; +import { GLOBAL_ONLY_FEATURES } from '@/lib/admin/policy-scopes'; // `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized // forward on policy load), so it is hidden from the admin UI. const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled']; const FEATURE_GATE_LABELS: Partial> = { - sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' }, - settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' }, - customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' }, - templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' }, - calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' }, - calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' }, - contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' }, - smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing', term: 'smime' }, - externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' }, - debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' }, - folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' }, - hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' }, - tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' }, - filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV', term: 'webdav' }, - crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, - crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, - crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, - unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' }, - accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. Mailbox passwords are stored only when the Passwords option below is on. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' }, - accountArchivePasswordsEnabled: { label: 'Account List Backup: Passwords', description: 'With Account List Backup on, offer to include the mailbox passwords of accounts signed in with a password, so a restore signs in to them by itself. They are encrypted in the browser with the backup password together with the list; the server never sees them in clear, but anyone who learns the backup password gets every mailbox in it. Accounts signed in with OAuth or a TOTP code are saved without.' }, - customFromEnabled: { label: 'Custom From Address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' }, - archiveActionEnabled: { label: 'Archive Action', description: 'Offer "Archive" on messages: the toolbar button, the context menu and selection bar, the hover and swipe actions, the E shortcut and archiving after an export. Turn this off when mail is archived elsewhere, for example by a mail gateway; existing Archive folders stay as ordinary folders and Bulwark no longer creates one.' }, - tagsAboveSharedEnabled: { label: 'Tags Above Shared Mailboxes', description: 'List the Tags in the sidebar above the shared mailboxes instead of below them. Turn this on when users have many shared mailboxes and work with tags: expanded, the shared mailboxes push the tags out of sight.' }, - structuredMailEnabled: { label: 'Booking and Parcel Cards', description: 'Show a card above booking, shipping and order mails that carry schema.org markup (flights, trains, buses, hotels, rental cars, events, restaurants, parcels, orders), with copy buttons, links and "Add to calendar". Cards appear only for mail whose sender passed SPF or DKIM for the From domain, and nothing is added to a calendar without a click. Everything is read in the browser; no request goes to airlines or carriers. Turn this off if mail content should not be turned into calendar entries, or if you don\'t trust the Authentication-Results your mail server writes.' }, + sidebarAppsEnabled: { label: 'Sidebar apps', description: 'Let users add their own web apps and links to the sidebar.' }, + settingsExportEnabled: { label: 'Settings backup', description: 'Let users download a copy of their settings and restore it from a file.' }, + customKeywordsEnabled: { label: 'Own tags', description: 'Let users create their own tags for messages.' }, + templatesEnabled: { label: 'Email templates', description: 'Let users save messages as templates and use the template library.' }, + calendarEnabled: { label: 'Calendar', description: 'Show the calendar.' }, + calendarTasksEnabled: { label: 'Tasks', description: 'Show the task list in the calendar.' }, + contactsEnabled: { label: 'Contacts', description: 'Show contacts and address books.' }, + smimeEnabled: { label: 'S/MIME', description: 'Let users add certificates to sign and encrypt mail.', term: 'smime' }, + externalContentEnabled: { label: 'Choice of remote content', description: 'Let users choose whether pictures and other content from the web load in messages.' }, + debugModeEnabled: { label: 'Debug mode', description: 'Let users turn on extra logging when they report a problem.' }, + folderIconsEnabled: { label: 'Folder icons', description: 'Let users pick an icon for each folder.' }, + hoverActionsConfigEnabled: { label: 'Hover buttons', description: 'Let users choose which buttons show when they point at a message in the list.' }, + tabTitleSubjectEnabled: { label: 'Subject in the tab title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' }, + filesEnabled: { label: 'Files', description: 'Show file storage (WebDAV).', term: 'webdav' }, + crossUnreadViewEnabled: { label: 'Unified mailbox: Unread', description: 'Offer an "Unread" entry in the sidebar that lists unread mail from the account and its shared folders, or from every account when Cross-account is on. Users turn it on in Settings > Appearance.' }, + crossStarredViewEnabled: { label: 'Unified mailbox: Starred', description: 'Offer a "Starred" entry in the sidebar that lists starred mail from the account and its shared folders, or from every account when Cross-account is on. Users turn it on in Settings > Appearance.' }, + crossAllViewEnabled: { label: 'Unified mailbox: All mail', description: 'Offer an "All mail" entry in the sidebar that lists all mail from the account and its shared folders, or from every account when Cross-account is on. Users turn it on in Settings > Appearance.' }, + unifiedCrossAccountEnabled: { label: 'Unified mailbox: Cross-account', description: 'Let the unified mailbox entries above list mail from every account a user is signed in to. Off keeps them to the account in view and its shared folders.' }, + accountArchiveEnabled: { label: 'Account list backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. Mailbox passwords are stored only when the Passwords option below is on. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' }, + accountArchivePasswordsEnabled: { label: 'Account list backup: passwords', description: 'With Account list backup on, offer to include the mailbox passwords of accounts signed in with a password, so a restore signs in to them by itself. They are encrypted in the browser with the backup password together with the list; the server never sees them in clear, but anyone who learns the backup password gets every mailbox in it. Accounts signed in with OAuth or a TOTP code are saved without.' }, + customFromEnabled: { label: 'Any sender address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' }, + archiveActionEnabled: { label: 'Archive', description: 'Offer "Archive" on messages: the toolbar button, the context menu and selection bar, the hover and swipe actions, the E shortcut and archiving after an export. Turn this off when mail is archived elsewhere, for example by a mail gateway; existing Archive folders stay as ordinary folders and Bulwark no longer creates one.' }, + tagsAboveSharedEnabled: { label: 'Tags above shared mailboxes', description: 'List the tags in the sidebar above the shared mailboxes instead of below them. Turn this on when users have many shared mailboxes and work with tags: expanded, the shared mailboxes push the tags out of sight.' }, + structuredMailEnabled: { label: 'Booking and parcel cards', description: 'Show a card above booking, shipping and order mails that carry schema.org markup (flights, trains, buses, hotels, rental cars, events, restaurants, parcels, orders), with copy buttons, links and "Add to calendar". Cards appear only for mail whose sender passed SPF or DKIM for the From domain, and nothing is added to a calendar without a click. Everything is read in the browser; no request goes to airlines or carriers. Turn this off if mail content should not be turned into calendar entries, or if you don\'t trust the Authentication-Results your mail server writes.' }, }; const PAGE = adminPageDef('policy'); @@ -101,11 +103,12 @@ export function PolicyPage() { } } - function toggleFeature(key: keyof FeatureGates) { - setPolicy(prev => ({ - ...prev, - features: { ...prev.features, [key]: !prev.features[key] }, - })); + const [scopeId, setScopeId] = useScopeId(policy); + const scoped = scopeId !== EVERYONE_SCOPE; + + /** A feature at Everyone or the picked domain or group; undefined there means Everyone's again. */ + function setFeature(key: keyof FeatureGates, value: boolean | undefined) { + setPolicy(prev => withFeatureAt(prev, scopeId, key, value)); setDirty(true); setMessage(null); } @@ -238,7 +241,7 @@ export function PolicyPage() { } else { setSavedPolicy(policy); } - setMessage({ type: 'success', text: 'Policy saved. Users see the change on their next login.' }); + setMessage({ type: 'success', text: 'Saved. Users get the change within 15 minutes.' }); setDirty(false); } else { const data = await res.json().catch(() => ({})); @@ -270,17 +273,35 @@ export function PolicyPage() { + {(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[]) .filter(key => !EXCLUDED_FEATURE_GATES.includes(key)) .map(key => { const meta = FEATURE_GATE_LABELS[key]; if (!meta) return null; const { label, description, term } = meta; + // The server checks these itself and can't tell users apart there. + const globalOnly = GLOBAL_ONLY_FEATURES.has(key); + const at = featureAt(policy, globalOnly ? EVERYONE_SCOPE : scopeId, key); return ( - toggleFeature(key)} /> + setFeature(key, v)} + /> + {scoped && globalOnly ? ( +

Applies to everyone

+ ) : ( + setFeature(key, undefined)} + /> + )}
); })} @@ -289,7 +310,7 @@ export function PolicyPage() {
@@ -390,7 +411,7 @@ export function PolicyPage() {
@@ -566,7 +587,7 @@ export function PolicyPage() { onSave={handleSave} onDiscard={discard} saveLabel="Save policy" - note="Users see the change on their next login" + note="Users get the change within 15 minutes" />
); diff --git a/components/admin/admin-page-icons.ts b/components/admin/admin-page-icons.ts index e8d6903e9..7072eed16 100644 --- a/components/admin/admin-page-icons.ts +++ b/components/admin/admin-page-icons.ts @@ -60,7 +60,9 @@ export const ADMIN_GROUPS = ['top', 'organisation', 'extensions', 'system'] as c export const ADMIN_GROUP_LABELS: Record = { top: null, - organisation: 'Organisation', + // Everything about what users get: features, settings, and the + // organisation pages, each with the same "Applies to" picker. + organisation: 'Users', extensions: 'Extensions', system: 'System', }; diff --git a/components/admin/org/policy-file-section.tsx b/components/admin/org/policy-file-section.tsx index c3365bce0..526754ec1 100644 --- a/components/admin/org/policy-file-section.tsx +++ b/components/admin/org/policy-file-section.tsx @@ -99,7 +99,7 @@ export function PolicyFileSection({ draft }: { draft: OrgPolicyDraft }) {