'use client'; import { useEffect, useState } from 'react'; import { Save, Loader2, Lock } from 'lucide-react'; import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types'; import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled']; 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' }, 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' }, filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' }, allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' }, crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' }, crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' }, crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' }, }; const RESTRICTABLE_SETTINGS = [ { key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] }, { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] }, { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' }, { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] }, { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] }, { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' }, { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] }, { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] }, { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' }, { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' }, { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' }, { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' }, { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' }, { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' }, ]; export function PolicyTab() { const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [dirty, setDirty] = useState(false); useEffect(() => { fetchPolicy(); }, []); async function fetchPolicy() { setLoading(true); try { const res = await apiFetch('/api/admin/policy'); if (res.ok) { const data = await res.json(); setPolicy(data); } } finally { setLoading(false); } } function toggleFeature(key: keyof FeatureGates) { setPolicy(prev => ({ ...prev, features: { ...prev.features, [key]: !prev.features[key] }, })); setDirty(true); setMessage(null); } function setPushRelayUrl(value: string) { setPolicy(prev => ({ ...prev, pushRelayUrl: value })); setDirty(true); setMessage(null); } function togglePushRelayLocked() { setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked })); setDirty(true); setMessage(null); } function toggleLocked(settingKey: string) { setPolicy(prev => { const existing = prev.restrictions[settingKey] || {}; const newRestrictions = { ...prev.restrictions }; if (existing.locked) { delete newRestrictions[settingKey]; } else { newRestrictions[settingKey] = { ...existing, locked: true }; } return { ...prev, restrictions: newRestrictions }; }); setDirty(true); setMessage(null); } function toggleHidden(settingKey: string) { setPolicy(prev => { const existing = prev.restrictions[settingKey] || {}; const newRestrictions = { ...prev.restrictions }; newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden }; if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) { delete newRestrictions[settingKey]; } return { ...prev, restrictions: newRestrictions }; }); setDirty(true); setMessage(null); } async function handleSave() { setSaving(true); setMessage(null); const res = await apiFetch('/api/admin/policy', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(policy), }); if (res.ok) { setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' }); setDirty(false); } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Failed to save' }); } setSaving(false); } if (loading) { return
Loading...
; } const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))]; return (

User Policy

Control which features and settings users can access

{dirty && ( )}
{message && (
{message.text}
)}

Feature Gates

Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.

{(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 } = meta; const enabled = policy.features[key]; return (
{label}

{description}

); })}

Push Relay

Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.

setPushRelayUrl(e.target.value)} placeholder="https://notifications.relay.example.com" className="w-full rounded border border-input bg-background px-3 py-2 text-sm" />
{categories.map(category => (

{category}

{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { const restriction = policy.restrictions[setting.key] || {}; return (
{setting.label}
); })}
))}
); }