Add cross-account aggregate mail views and make group/shared (delegated) accounts first-class in every aggregate view. (The unified mailbox, the "All Mail" view, and "include group inboxes" already exist on main; this branch adds the cross-account views and the shared-account correctness work.) New views (admin-gated + per-user toggle, nested under Unified Mailbox): - Cross-account "All accounts": All unread / All starred / All mail across every connected account, including shared/group folders. Each list labels the source folder of every message. Source reference on aggregated emails (the core of the shared-account work): - Replace the overloaded `accountId` with two explicit, always-set fields: `sourceClientAccountId` (the login the mail is reachable through) and `sourceAccountId` (the owning JMAP account). `accountId` stays display-only. - Resolution is branch-free everywhere: pick the client by sourceClientAccountId, pass sourceAccountId as the JMAP accountId (no-op for personal), read the owner's mailbox list cached by JMAP id. No capability scan. Shared/group-account correctness across all aggregate views: - Route open (click + auto-fetch), thread/conversation open + reply-refresh, mark read, star, move, delete (account-scoped trash), archive (owner-routed createMailbox / fetchAccountMailboxes), and spam + undo via the source ref. - Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails / createMailbox where missing. - Fix local unread/total counter math for shared folders via emailInMailbox() (matches namespaced shared ids and bare own ids). - Keep the unified/cross virtual selection on background mailbox refresh (no jump back to inbox after deleting in All Drafts/Junk). Junk UX: - In "All Junk" the spam action becomes "not spam" in the viewer, context menu, and list hover icons; undo routes shared mail back to its own inbox. Admin: - Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching per-user toggle (allMailViewEnabled clarified too). i18n / docs / tests: - locales (19): cross-view labels + descriptions and hover not_spam, translated in all shipped languages. - FEATURES.md + README.md document the new views and group-account support. - Tests for shared-account routing (single + batch + undoSpam), decoration, and unified-selection preservation.
262 lines
14 KiB
TypeScript
262 lines
14 KiB
TypeScript
'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<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||
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' },
|
||
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<SettingsPolicy>({ ...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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||
}
|
||
|
||
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
||
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
|
||
</div>
|
||
{dirty && (
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||
>
|
||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||
Save policy
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{message && (
|
||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||
{message.text}
|
||
</div>
|
||
)}
|
||
|
||
<div className="border border-border rounded-lg">
|
||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
|
||
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
|
||
</div>
|
||
<div className="divide-y divide-border">
|
||
{(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 (
|
||
<div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||
<div className="min-w-0">
|
||
<span className="text-sm text-foreground">{label}</span>
|
||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||
</div>
|
||
<button onClick={() => toggleFeature(key)}
|
||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border border-border rounded-lg">
|
||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||
<h2 className="text-sm font-medium text-foreground">Push Relay</h2>
|
||
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
|
||
</div>
|
||
<div className="px-4 py-3 space-y-3">
|
||
<input
|
||
type="url"
|
||
inputMode="url"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
value={policy.pushRelayUrl ?? ''}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={!!policy.pushRelayUrlLocked}
|
||
onChange={togglePushRelayLocked}
|
||
className="rounded border-input"
|
||
/>
|
||
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{categories.map(category => (
|
||
<div key={category} className="border border-border rounded-lg">
|
||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||
<h2 className="text-sm font-medium text-foreground">{category}</h2>
|
||
</div>
|
||
<div className="divide-y divide-border">
|
||
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||
const restriction = policy.restrictions[setting.key] || {};
|
||
return (
|
||
<div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||
<span className="text-sm text-foreground">{setting.label}</span>
|
||
<div className="flex items-center gap-3 shrink-0">
|
||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||
className="rounded border-input" />
|
||
<Lock className="w-3 h-3" /> Lock
|
||
</label>
|
||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
||
className="rounded border-input" />
|
||
Hide
|
||
</label>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|