feat: put user features and settings with the organisation pages under Users and let features apply to one domain or group

The admin skeleton test and the outgoing-hosts path test now expect the
sidebar heading "Users" and the page "Features and settings": the
Organisation group was renamed and User policy moved into it.
This commit is contained in:
Linus Rath
2026-11-04 08:22:41 +01:00
parent 5a7f22b7cf
commit 16273e447f
12 changed files with 197 additions and 57 deletions
@@ -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<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
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<HTMLElement>('[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(<PolicyPage />);
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<string, boolean>; scopes: Array<{ id: string; policy: { features?: Record<string, boolean> } }> };
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(<PolicyPage />);
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(<PolicyPage />);
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();
});
});
+57 -36
View File
@@ -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<Record<keyof FeatureGates, { label: string; description: string; term?: GlossaryId }>> = {
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() {
<SettingsSection
id="features"
title="Features"
description="Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages."
description="Turn whole features on or off for users. Plugins and themes have their own pages."
>
<ScopePicker policy={policy} scopeId={scopeId} onPick={setScopeId} />
{(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 (
<SettingItem key={key} label={label} description={description} term={term}>
<ToggleSwitch checked={!!policy.features[key]} onChange={() => toggleFeature(key)} />
<ToggleSwitch
checked={at.value}
disabled={scoped && globalOnly}
onChange={(v) => setFeature(key, v)}
/>
{scoped && globalOnly ? (
<p className="mt-1 text-xs text-muted-foreground">Applies to everyone</p>
) : (
<InheritLine
scoped={scoped}
own={at.own}
everyone={at.everyone ? 'On' : 'Off'}
onReset={() => setFeature(key, undefined)}
/>
)}
</SettingItem>
);
})}
@@ -289,7 +310,7 @@ export function PolicyPage() {
<SettingsSection
id="push-relays"
title="Push relays"
description="Relays users can pick from in notification settings. The built-in Bulwark relay is always offered; add your own here. Users choose from this list - they cannot enter a URL."
description="Relays users can pick from in notification settings. The built-in Bulwark relay is always offered; add your own here. Users choose from this list - they cannot enter a URL. Applies to everyone."
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
@@ -390,7 +411,7 @@ export function PolicyPage() {
<SettingsSection
id="sidebar-apps"
title="Default sidebar apps"
description="Apps every user gets in the navigation rail without configuring anything. They appear above the user's own apps and cannot be edited or removed by users. Independent of the Sidebar Apps feature gate above, which only governs user-added apps."
description="Apps every user gets in the navigation rail without configuring anything. They appear above the user's own apps and cannot be edited or removed by users. Independent of the Sidebar apps feature above, which only covers apps users add themselves. Applies to everyone."
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
@@ -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"
/>
</div>
);
+3 -1
View File
@@ -60,7 +60,9 @@ export const ADMIN_GROUPS = ['top', 'organisation', 'extensions', 'system'] as c
export const ADMIN_GROUP_LABELS: Record<AdminPageDef['group'], string | null> = {
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',
};
+1 -1
View File
@@ -99,7 +99,7 @@ export function PolicyFileSection({ draft }: { draft: OrgPolicyDraft }) {
<SettingsSection
id="policy-file"
title="Policy file"
description="Everything on the Organisation and User policy pages is one file, policy.json. Export it to keep it in git or copy it to another Bulwark or a Lite build; importing replaces the current policy."
description="Everything on the pages under Users is one file, policy.json. Export it to keep it in git or copy it to another Bulwark or a Lite build; importing replaces the current policy."
>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => void exportFile()}>
@@ -139,7 +139,7 @@ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
{on && relays.length === 0 && (
<ConfigNote>
Push notifications now need a relay you run yourself. Add one under{' '}
<AdminLink href="/admin/policy" className="underline underline-offset-2">User policy &gt; Push relays</AdminLink>;
<AdminLink href="/admin/policy" className="underline underline-offset-2">Features and settings &gt; Push relays</AdminLink>;
until then users get new mail only while Bulwark is open.
</ConfigNote>
)}
@@ -50,7 +50,7 @@ const UNLOCKABLE_NOTE =
'Text size, contrast, motion, fonts and the reading aids can be given a default below but not locked: a lock could leave someone unable to read or use Bulwark.';
/** The scope this page edits: the one picked on any Organisation page, if it still exists. */
function useScopeId(policy: SettingsPolicy): [string, (id: string) => void] {
export function useScopeId(policy: SettingsPolicy): [string, (id: string) => void] {
const scopeId = useOrgScopeStore((s) => s.scopeId);
const setScopeId = useOrgScopeStore((s) => s.setScopeId);
const exists = scopeId === EVERYONE || (policy.scopes ?? []).some((s) => s.id === scopeId);
@@ -58,7 +58,7 @@ function useScopeId(policy: SettingsPolicy): [string, (id: string) => void] {
}
/** Everyone or one domain or group scope, for the locks and presets below it. */
function ScopePicker({ policy, scopeId, onPick }: { policy: SettingsPolicy; scopeId: string; onPick: (id: string) => void }) {
export function ScopePicker({ policy, scopeId, onPick }: { policy: SettingsPolicy; scopeId: string; onPick: (id: string) => void }) {
const scopes = policy.scopes ?? [];
if (scopes.length === 0) return null;
const scope = scopes.find((s) => s.id === scopeId);
@@ -95,7 +95,7 @@ function ScopePicker({ policy, scopeId, onPick }: { policy: SettingsPolicy; scop
}
/** "From Everyone: On" under a row, with a way back to Everyone's value. */
function InheritLine({ scoped, own, everyone, onReset }: { scoped: boolean; own: boolean; everyone: string; onReset: () => void }) {
export function InheritLine({ scoped, own, everyone, onReset }: { scoped: boolean; own: boolean; everyone: string; onReset: () => void }) {
if (!scoped) return null;
return (
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
@@ -51,7 +51,7 @@ describe('admin skeleton', () => {
it('draws the page list with its real names and the open page current', () => {
const container = dom(renderToString(<AdminSkeleton page="branding" label="Loading" />));
const text = container.textContent ?? '';
for (const name of ['Overview', 'Server', 'Branding', 'Organisation', 'Extensions', 'System', 'Audit log']) {
for (const name of ['Overview', 'Server', 'Branding', 'Users', 'Features and settings', 'Extensions', 'System', 'Audit log']) {
expect(text).toContain(name);
}
const current = [...container.querySelectorAll('.bg-accent')].map((el) => el.textContent);
@@ -8,7 +8,7 @@ import { OUTGOING_HOSTS } from '../outgoing-hosts-registry';
const HEADINGS: Record<string, AdminPageGroup> = {
Admin: 'top',
Organisation: 'organisation',
Users: 'organisation',
Extensions: 'extensions',
System: 'system',
};
@@ -20,7 +20,7 @@ describe('admin directions in the outgoing-hosts registry', () => {
const problems: string[] = [];
let found = 0;
for (const { id, text } of texts) {
for (const m of text.matchAll(/\b(Admin|Organisation|Extensions|System) > /g)) {
for (const m of text.matchAll(/\b(Admin|Users|Extensions|System) > /g)) {
found++;
const rest = text.slice(m.index! + m[0].length);
const page = ADMIN_PAGES.find((p) => rest.startsWith(p.label));
+4 -4
View File
@@ -119,10 +119,10 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
},
{
id: 'policy',
label: 'User policy',
description: 'Which features users get and which settings they may change',
group: 'top',
keywords: 'features gates restrictions lock hide users',
label: 'Features and settings',
description: 'Which features users get, which settings they may change, and what they start with, for everyone or one domain or group',
group: 'organisation',
keywords: 'user policy features gates restrictions lock hide users defaults presets scope domain group',
sections: [
{ id: 'features', title: 'Features', keywords: 'calendar contacts files smime templates keywords sidebar apps unified mailbox archive custom from debug export' },
{ id: 'push-relays', title: 'Push relays', keywords: 'notifications relay url default lock' },
+1 -1
View File
@@ -60,7 +60,7 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
files: ['lib/push-relays.ts', 'app/api/dev-jmap/[...path]/route.ts'],
side: 'browser',
usedBy: 'Built-in Web Push relay that wakes the browser for new mail.',
off: 'Add your own relays under Admin > User policy > Push relays and lock the choice.',
off: 'Add your own relays under Users > Features and settings > Push relays and lock the choice.',
inMode: 'Not offered. Push works only with a relay the admin configured; existing registrations move to it or are removed.',
lite: false,
},
+27 -2
View File
@@ -1,6 +1,6 @@
/**
* The settings an admin can preset (policy `defaults`) and lock with a value
* (restriction `value` / `min`) on the User policy page, and the pure edits
* (restriction `value` / `min`) on the Features and settings page, and the pure edits
* the page makes to a policy, for Everyone or for one domain or group scope.
*
* Labels are the ones users see in their own settings (English, like the
@@ -9,7 +9,7 @@
* locales/en/common.json and the defaults against the settings store.
*/
import type { SettingRestriction, SettingsPolicy } from './types';
import { DEFAULT_FEATURE_GATES, type FeatureGates, type SettingRestriction, type SettingsPolicy } from './types';
import { RESTRICTION_KEYS } from './restriction-keys';
import {
ACCESSIBILITY_DEFAULTS,
@@ -352,6 +352,31 @@ export function withRestrictionAt(
return next;
}
/** Whether a feature is on at the scope: its own value, or Everyone's. */
export function featureAt(policy: SettingsPolicy, scopeId: string, key: keyof FeatureGates): ScopedValue<boolean> {
const everyone = policy.features?.[key] ?? DEFAULT_FEATURE_GATES[key];
const scope = scopeOf(policy, scopeId);
if (!scope) return { value: everyone, own: true, everyone };
const features = scope.policy.features;
return hasOwn(features, key)
? { value: features![key] as boolean, own: true, everyone }
: { value: everyone, own: false, everyone };
}
/** The policy with `key` on or off at the scope; `undefined` in a scope means Everyone's again. */
export function withFeatureAt(policy: SettingsPolicy, scopeId: string, key: keyof FeatureGates, value: boolean | undefined): SettingsPolicy {
const next = clonePolicy(policy);
const scope = scopeOf(next, scopeId);
if (!scope) {
next.features = { ...next.features, [key]: value ?? DEFAULT_FEATURE_GATES[key] };
return next;
}
const features = (scope.policy.features ??= {});
if (value === undefined) delete features[key];
else features[key] = value;
return next;
}
export function defaultAt(policy: SettingsPolicy, scopeId: string, key: string): ScopedValue<unknown> {
const everyone = hasOwn(policy.defaults, key) ? policy.defaults[key] : undefined;
const scope = scopeOf(policy, scopeId);
+5 -5
View File
@@ -19,7 +19,7 @@ export interface ReachableHost {
host: string;
/** scheme://host[:port], for the CSP; absent for addresses users enter. */
origin?: string;
/** Where it was configured, e.g. "JMAP_SERVER_URL" or "User policy > Push relays". */
/** Where it was configured, e.g. "JMAP_SERVER_URL" or "Features and settings > Push relays". */
setIn: string;
/** Who connects: this server, the browser, or both. */
side: 'server' | 'browser' | 'both';
@@ -83,8 +83,8 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R
add('OAuth issuer', get('oauthAuthorizeUrl'), 'OAUTH_AUTHORIZE_URL', 'browser');
add('OAuth issuer', get('oauthEndSessionUrl'), 'OAUTH_END_SESSION_URL', 'browser');
}
for (const relay of policy.pushRelays ?? []) add('Push relay', relay?.url, 'User policy > Push relays', 'browser');
add('Push relay', policy.pushRelayUrl, 'User policy > Push relays', 'browser');
for (const relay of policy.pushRelays ?? []) add('Push relay', relay?.url, 'Features and settings > Push relays', 'browser');
add('Push relay', policy.pushRelayUrl, 'Features and settings > Push relays', 'browser');
// The relay browsers offer as the default is the one inlined when the
// bundle was built. A different value in the running environment does
// nothing for them, so it is listed with a warning.
@@ -92,7 +92,7 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R
const runtimeRelay = parse(env.NEXT_PUBLIC_PUSH_RELAY_URL);
if (runtimeRelay && runtimeRelay.origin !== parse(buildRelayUrl)?.origin) {
add('Push relay', runtimeRelay.href, 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', 'browser',
'Set in the environment only: browsers use the relay this build was made with. Rebuild, or add it under User policy > Push relays.');
'Set in the environment only: browsers use the relay this build was made with. Rebuild, or add it under Features and settings > Push relays.');
}
add('Extension directory', get('extensionDirectoryUrl'), 'EXTENSION_DIRECTORY_URL', 'server');
// Company calendars of Everyone and of every scope: this server fetches
@@ -103,7 +103,7 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R
for (const plugin of plugins) {
for (const origin of plugin.origins) add(`Plugin ${plugin.name}`, origin, "The plugin's admin page", 'server');
}
for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `User policy > Sidebar apps (${app?.name ?? ''})`, 'browser');
for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `Features and settings > Default sidebar apps (${app?.name ?? ''})`, 'browser');
add('Document editor (WOPI)', get('wopiClientUrl'), 'WOPI_CLIENT_URL', 'both');
add('Translation (LibreTranslate)', env.LIBRETRANSLATE_URL, 'LIBRETRANSLATE_URL', 'server');
// Only when an admin set it; browsers and this server send error reports there.