feat: manage shared/group account settings from Accounts page
This commit is contained in:
@@ -74,6 +74,7 @@ import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
@@ -385,6 +386,12 @@ export default function SettingsPage() {
|
||||
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
|
||||
// When set, the settings panel is scoped to a shared/group account: a reduced
|
||||
// tab list and a "Managing: <name>" header. null = the user's own account.
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
|
||||
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
|
||||
|
||||
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||
// (individual settings) per tab. Sub-results come from translation entries
|
||||
// that have a `label`/`title` field, plus dynamic content (installed
|
||||
@@ -488,6 +495,10 @@ export default function SettingsPage() {
|
||||
return () => window.removeEventListener('settings-tab-change', handler);
|
||||
}, []);
|
||||
|
||||
// Leaving the settings panel drops any shared-account scope so it never
|
||||
// leaks into the next visit or another session.
|
||||
useEffect(() => () => clearManagedAccount(), [clearManagedAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
@@ -626,12 +637,28 @@ export default function SettingsPage() {
|
||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||
];
|
||||
|
||||
// In scoped (shared-account) mode, restrict to the account-relevant tabs the
|
||||
// account actually advertises. Folders is intentionally excluded (mailbox CRUD
|
||||
// is hardwired to the active account). Gated on both the per-account
|
||||
// capability and the session-level support/feature flags.
|
||||
const scopedTabIds: Tab[] = managedAccount
|
||||
? ([
|
||||
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
|
||||
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
|
||||
managedAccount.capabilities.calendars && supportsCalendar ? 'calendar' : null,
|
||||
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
|
||||
].filter(Boolean) as Tab[])
|
||||
: [];
|
||||
const visibleTabs = managedAccountId
|
||||
? tabs.filter((tab) => scopedTabIds.includes(tab.id))
|
||||
: tabs;
|
||||
|
||||
// Group tabs by category
|
||||
const groupedTabs = tabGroupOrder
|
||||
.map((group) => ({
|
||||
group,
|
||||
label: t(`tab_groups.${group}`),
|
||||
items: tabs.filter((tab) => tab.group === group),
|
||||
items: visibleTabs.filter((tab) => tab.group === group),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
@@ -659,9 +686,13 @@ export default function SettingsPage() {
|
||||
.filter((g) => g.items.length > 0)
|
||||
: groupedTabs;
|
||||
|
||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
||||
// If active tab is not in the visible list (e.g., feature disabled, or scoped
|
||||
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||
// otherwise the usual 'appearance' default.
|
||||
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||
const effectiveActiveTab: Tab = isActiveVisible
|
||||
? activeTab
|
||||
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
||||
|
||||
const handleTabSelect = (tabId: Tab) => {
|
||||
setActiveTab(tabId);
|
||||
@@ -676,10 +707,26 @@ export default function SettingsPage() {
|
||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||
};
|
||||
|
||||
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||
|
||||
const renderTabContent = () => (
|
||||
<>
|
||||
{managedAccountId && managedAccount && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearManagedAccount();
|
||||
handleTabSelect('account');
|
||||
}}
|
||||
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
|
||||
<span className="ml-auto text-sm font-medium truncate">
|
||||
{t('scoped.managing', { name: managedAccount.name })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||
@@ -697,8 +744,16 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'calendar' && (
|
||||
managedAccountId
|
||||
? <CalendarManagementSettings />
|
||||
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
|
||||
)}
|
||||
{effectiveActiveTab === 'contacts' && (
|
||||
managedAccountId
|
||||
? <AddressBookManagementSettings />
|
||||
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
|
||||
)}
|
||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react';
|
||||
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import type { SharedAccount } from '@/lib/jmap/types';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -17,12 +19,30 @@ function hostnameOf(serverUrl: string): string {
|
||||
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
|
||||
}
|
||||
|
||||
// First scoped settings tab to land on for a shared account, by capability.
|
||||
// Mirrors the scoped-tab gating in the settings page. null = nothing editable.
|
||||
function firstScopedTab(caps: SharedAccount['capabilities']): string | null {
|
||||
if (caps.sieve) return 'filters';
|
||||
if (caps.mail) return 'vacation';
|
||||
if (caps.calendars) return 'calendar';
|
||||
if (caps.contacts) return 'contacts';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AccountSettings() {
|
||||
const t = useTranslations('settings.account');
|
||||
const router = useRouter();
|
||||
const { username, serverUrl, isDemoMode, primaryIdentity, authMode } = useAuthStore();
|
||||
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
|
||||
|
||||
// Shared/group accounts delegated to this session (excludes the user's own
|
||||
// primary account). These can be drilled into for scoped settings editing.
|
||||
const sharedAccounts = useMemo<SharedAccount[]>(
|
||||
() => (client?.getSharedAccounts() ?? []).filter((a) => !a.isPrimary),
|
||||
[client],
|
||||
);
|
||||
const { quota } = useEmailStore();
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||
@@ -82,6 +102,16 @@ export function AccountSettings() {
|
||||
router.push(`/login?mode=add-account` as never);
|
||||
}, [router]);
|
||||
|
||||
// Enter scoped settings mode for a shared/group account: set the managed
|
||||
// account, then steer the settings panel to its first editable tab via the
|
||||
// existing 'settings-tab-change' event the page already listens for.
|
||||
const handleManageShared = useCallback((account: SharedAccount) => {
|
||||
const tab = firstScopedTab(account.capabilities);
|
||||
if (!tab) return;
|
||||
setManagedAccount(account);
|
||||
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: tab }));
|
||||
}, [setManagedAccount]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -197,6 +227,44 @@ export function AccountSettings() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Shared / group accounts delegated to this session. Clicking one drills
|
||||
into a scoped settings view (filters, vacation, calendars, contacts). */}
|
||||
{sharedAccounts.length > 0 && (
|
||||
<SettingsSection title={t('shared_accounts.title')} description={t('shared_accounts.description')}>
|
||||
<div className="space-y-2">
|
||||
{sharedAccounts.map((acc) => {
|
||||
const editable = firstScopedTab(acc.capabilities) !== null;
|
||||
return (
|
||||
<button
|
||||
key={acc.id}
|
||||
type="button"
|
||||
onClick={() => handleManageShared(acc)}
|
||||
disabled={!editable}
|
||||
className={cn(
|
||||
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-left transition-colors',
|
||||
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
name={acc.name}
|
||||
size="sm"
|
||||
className="w-9 h-9 text-sm flex-shrink-0"
|
||||
disableFavicon
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{acc.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{t('shared_accounts.shared_label')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -71,6 +72,7 @@ export function AddressBookManagementSettings() {
|
||||
const tContacts = useTranslations("contacts");
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
@@ -205,9 +207,13 @@ export function AddressBookManagementSettings() {
|
||||
<>
|
||||
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
|
||||
<div className="space-y-2">
|
||||
{personal.map(renderBook)}
|
||||
{/* In scoped mode (managing a shared account) hide the user's own
|
||||
address books and show only the managed account's group. */}
|
||||
{(managedAccountId ? [] : personal).map(renderBook)}
|
||||
|
||||
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
|
||||
{Array.from(sharedGroups.entries())
|
||||
.filter(([accountId]) => !managedAccountId || accountId === managedAccountId)
|
||||
.map(([accountId, group]) => (
|
||||
<div key={accountId} className="mt-4 space-y-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
@@ -223,6 +229,9 @@ export function AddressBookManagementSettings() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Contact categories come from the active account's contacts, not the
|
||||
shared account, so hide them while scoped to a shared account. */}
|
||||
{!managedAccountId && (
|
||||
<div className="mt-8">
|
||||
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
|
||||
<div className="space-y-2">
|
||||
@@ -267,6 +276,7 @@ export function AddressBookManagementSettings() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharingId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingId);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
|
||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
|
||||
|
||||
@@ -137,6 +138,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
|
||||
export function CalendarManagementSettings() {
|
||||
const t = useTranslations('calendar.management');
|
||||
const { client, serverUrl, username } = useAuthStore();
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||
// Subscriptions are persisted globally but scoped per JMAP account via
|
||||
// accountId. Legacy entries with no accountId show in the active account.
|
||||
@@ -388,7 +390,10 @@ export function CalendarManagementSettings() {
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="space-y-2">
|
||||
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
|
||||
{calendars
|
||||
.filter(cal => !isSubscriptionCalendar(cal.id))
|
||||
.filter(cal => !managedAccountId || cal.accountId === managedAccountId)
|
||||
.map((cal) => {
|
||||
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
|
||||
|
||||
if (editingId === cal.id) {
|
||||
|
||||
@@ -12,7 +12,9 @@ import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { FilterRule } from "@/lib/jmap/sieve-types";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||
import {
|
||||
Plus,
|
||||
GripVertical,
|
||||
@@ -157,7 +159,7 @@ export function FilterSettings() {
|
||||
const t = useTranslations("settings.filters");
|
||||
const tNotifications = useTranslations("notifications");
|
||||
const { client } = useAuthStore();
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const storeMailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
@@ -170,7 +172,7 @@ export function FilterSettings() {
|
||||
isOpaque,
|
||||
rawScript,
|
||||
vacationSettings,
|
||||
fetchFilters,
|
||||
selectAccount,
|
||||
saveFilters,
|
||||
addRule,
|
||||
updateRule,
|
||||
@@ -182,7 +184,41 @@ export function FilterSettings() {
|
||||
validateScript,
|
||||
} = useFilterStore();
|
||||
|
||||
const vacationEnabled = useVacationStore((s) => s.isEnabled) || vacationSettings?.isEnabled;
|
||||
// Scoped to a shared/group account when the settings panel is managing one.
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const isPrimaryAccount = !managedAccountId;
|
||||
|
||||
// Folders offered as "move to" targets in the rule editor must belong to the
|
||||
// account whose filters we're editing. For a shared account, fetch that
|
||||
// account's mailboxes (the email store only holds the active account's). They
|
||||
// are this account's own folders within its Sieve context, so present them as
|
||||
// non-shared — otherwise the rule modal filters them out (it drops isShared
|
||||
// mailboxes, which are normally other accounts' folders merged into the view).
|
||||
const [scopedMailboxes, setScopedMailboxes] = useState<Mailbox[]>([]);
|
||||
useEffect(() => {
|
||||
if (!client || !managedAccountId) {
|
||||
setScopedMailboxes([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void client
|
||||
.getMailboxes(managedAccountId)
|
||||
.then((mbs) => {
|
||||
if (!cancelled) setScopedMailboxes(mbs.map((mb) => ({ ...mb, isShared: false })));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setScopedMailboxes([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [client, managedAccountId]);
|
||||
|
||||
const mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes;
|
||||
|
||||
const vacationStoreEnabled = useVacationStore((s) => s.isEnabled);
|
||||
// Vacation uses a separate per-(primary)-account mechanism (RFC 9661), so the
|
||||
// "vacation active" banner only applies when editing the personal account.
|
||||
const vacationEnabled =
|
||||
isPrimaryAccount && (vacationStoreEnabled || vacationSettings?.isEnabled);
|
||||
|
||||
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
|
||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||
@@ -194,9 +230,12 @@ export function FilterSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
if (client && isSupported) {
|
||||
void fetchFilters(client);
|
||||
// Always pass a concrete account id (managed shared account, or the
|
||||
// primary Sieve account) so switching never falls back to a stale
|
||||
// previously-selected account.
|
||||
void selectAccount(client, managedAccountId ?? client.getSieveAccountId());
|
||||
}
|
||||
}, [client, isSupported, fetchFilters]);
|
||||
}, [client, isSupported, managedAccountId, selectAccount]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (ruleId: string) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
@@ -19,6 +20,7 @@ export function VacationSettings() {
|
||||
const t = useTranslations('settings.vacation');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const { client } = useAuthStore();
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const {
|
||||
isEnabled,
|
||||
fromDate,
|
||||
@@ -43,9 +45,9 @@ export function VacationSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
if (client && isSupported) {
|
||||
void fetchVacationResponse(client);
|
||||
void fetchVacationResponse(client, managedAccountId ?? undefined);
|
||||
}
|
||||
}, [client, isSupported, fetchVacationResponse]);
|
||||
}, [client, isSupported, managedAccountId, fetchVacationResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEnabled(isEnabled);
|
||||
@@ -101,7 +103,7 @@ export function VacationSettings() {
|
||||
toDate: localToDate || null,
|
||||
subject: localSubject,
|
||||
textBody: localTextBody,
|
||||
});
|
||||
}, managedAccountId ?? undefined);
|
||||
|
||||
toast.success(tNotifications('vacation_saved'));
|
||||
} catch (error) {
|
||||
|
||||
+26
-11
@@ -1,5 +1,5 @@
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types';
|
||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
import { getDemoData, type DemoData } from './demo-data';
|
||||
import { generateDemoId } from './demo-utils';
|
||||
@@ -118,7 +118,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Mailboxes ─────────────────────────────────────────────────
|
||||
|
||||
async getMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||
async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||
|
||||
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
|
||||
@@ -603,9 +603,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Vacation ──────────────────────────────────────────────────
|
||||
|
||||
async getVacationResponse(): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
|
||||
async getVacationResponse(_accountId?: string): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
|
||||
|
||||
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
|
||||
async setVacationResponse(updates: Partial<VacationResponse>, _accountId?: string): Promise<void> {
|
||||
Object.assign(this.data.vacationResponse, updates);
|
||||
}
|
||||
|
||||
@@ -849,19 +849,34 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Sieve / Filters ──────────────────────────────────────────
|
||||
|
||||
getSharedAccounts(): SharedAccount[] {
|
||||
return [{
|
||||
id: 'demo-account',
|
||||
name: 'Demo',
|
||||
isPrimary: true,
|
||||
capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: true },
|
||||
}];
|
||||
}
|
||||
|
||||
getSieveAccountId(): string { return 'demo-account'; }
|
||||
|
||||
getSieveCapabilities(): SieveCapabilities | null {
|
||||
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] {
|
||||
return this.getSharedAccounts()
|
||||
.filter((a) => a.isPrimary || a.capabilities.sieve)
|
||||
.map(({ id, name, isPrimary }) => ({ id, name, isPrimary }));
|
||||
}
|
||||
|
||||
getSieveCapabilities(_accountId?: string): SieveCapabilities | null {
|
||||
return { ...this.data.sieveCapabilities };
|
||||
}
|
||||
|
||||
async getSieveScripts(): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
|
||||
async getSieveScripts(_accountId?: string): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
|
||||
|
||||
async getSieveScriptContent(blobId: string): Promise<string> {
|
||||
async getSieveScriptContent(blobId: string, _accountId?: string): Promise<string> {
|
||||
return this.data.sieveContent[blobId] ?? '';
|
||||
}
|
||||
|
||||
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
|
||||
async createSieveScript(name: string, content: string, activate?: boolean, _accountId?: string): Promise<SieveScript> {
|
||||
const blobId = generateDemoId('sieve-blob');
|
||||
const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false };
|
||||
this.data.sieveScripts.push(script);
|
||||
@@ -874,7 +889,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return script;
|
||||
}
|
||||
|
||||
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
|
||||
async updateSieveScript(scriptId: string, content: string, activate?: boolean, _accountId?: string): Promise<void> {
|
||||
const script = this.data.sieveScripts.find(s => s.id === scriptId);
|
||||
if (!script) return;
|
||||
const blobId = generateDemoId('sieve-blob');
|
||||
@@ -890,11 +905,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSieveScript(scriptId: string): Promise<void> {
|
||||
async deleteSieveScript(scriptId: string, _accountId?: string): Promise<void> {
|
||||
this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId);
|
||||
}
|
||||
|
||||
async validateSieveScript(): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||
async validateSieveScript(_content?: string, _accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
@@ -71,7 +71,7 @@ export interface IJMAPClient {
|
||||
getQuota(): Promise<{ used: number; total: number } | null>;
|
||||
|
||||
// ── Mailboxes ─────────────────────────────────────────────────
|
||||
getMailboxes(): Promise<Mailbox[]>;
|
||||
getMailboxes(accountId?: string): Promise<Mailbox[]>;
|
||||
getAllMailboxes(): Promise<Mailbox[]>;
|
||||
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
|
||||
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
|
||||
@@ -239,8 +239,8 @@ export interface IJMAPClient {
|
||||
deleteIdentity(identityId: string): Promise<void>;
|
||||
|
||||
// ── Vacation ──────────────────────────────────────────────────
|
||||
getVacationResponse(): Promise<VacationResponse>;
|
||||
setVacationResponse(updates: Partial<VacationResponse>): Promise<void>;
|
||||
getVacationResponse(accountId?: string): Promise<VacationResponse>;
|
||||
setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void>;
|
||||
|
||||
// ── Contacts ──────────────────────────────────────────────────
|
||||
getContactsAccountId(): string;
|
||||
@@ -294,15 +294,19 @@ export interface IJMAPClient {
|
||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
|
||||
|
||||
// ── Accounts (primary + shared/group) ────────────────────────
|
||||
getSharedAccounts(): SharedAccount[];
|
||||
|
||||
// ── Sieve / Filters ──────────────────────────────────────────
|
||||
getSieveAccountId(): string;
|
||||
getSieveCapabilities(): SieveCapabilities | null;
|
||||
getSieveScripts(): Promise<SieveScript[]>;
|
||||
getSieveScriptContent(blobId: string): Promise<string>;
|
||||
createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript>;
|
||||
updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void>;
|
||||
deleteSieveScript(scriptId: string): Promise<void>;
|
||||
validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>;
|
||||
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[];
|
||||
getSieveCapabilities(accountId?: string): SieveCapabilities | null;
|
||||
getSieveScripts(accountId?: string): Promise<SieveScript[]>;
|
||||
getSieveScriptContent(blobId: string, accountId?: string): Promise<string>;
|
||||
createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript>;
|
||||
updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void>;
|
||||
deleteSieveScript(scriptId: string, accountId?: string): Promise<void>;
|
||||
validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }>;
|
||||
|
||||
// ── Files (WebDAV / FileNode) ─────────────────────────────────
|
||||
getFilesAccountId(): string;
|
||||
|
||||
+106
-45
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -897,16 +897,17 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getMailboxes(): Promise<Mailbox[]> {
|
||||
async getMailboxes(accountId?: string): Promise<Mailbox[]> {
|
||||
const acctId = accountId || this.accountId;
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Mailbox/get", { accountId: this.accountId }, "0"]
|
||||
["Mailbox/get", { accountId: acctId }, "0"]
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
||||
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
||||
|
||||
debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`);
|
||||
debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${acctId}`);
|
||||
|
||||
// Warn if response might be truncated
|
||||
const maxObjects = this.getMaxObjectsInGet();
|
||||
@@ -940,9 +941,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
unreadThreads: mb.unreadThreads ?? 0,
|
||||
myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS,
|
||||
isSubscribed: mb.isSubscribed ?? true,
|
||||
accountId: this.accountId,
|
||||
accountName: this.accounts[this.accountId]?.name || this.username,
|
||||
isShared: false,
|
||||
accountId: acctId,
|
||||
accountName: this.accounts[acctId]?.name || this.username,
|
||||
isShared: acctId !== this.accountId,
|
||||
}) as Mailbox);
|
||||
}
|
||||
|
||||
@@ -2082,10 +2083,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:vacationresponse"];
|
||||
}
|
||||
|
||||
async getVacationResponse(): Promise<VacationResponse> {
|
||||
async getVacationResponse(accountId?: string): Promise<VacationResponse> {
|
||||
const response = await this.request([
|
||||
["VacationResponse/get", {
|
||||
accountId: this.accountId,
|
||||
accountId: accountId || this.accountId,
|
||||
ids: ["singleton"],
|
||||
}, "0"]
|
||||
], this.vacationUsing());
|
||||
@@ -2109,10 +2110,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error("Failed to fetch vacation response: unexpected server response");
|
||||
}
|
||||
|
||||
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
|
||||
async setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
["VacationResponse/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: accountId || this.accountId,
|
||||
update: {
|
||||
"singleton": updates,
|
||||
},
|
||||
@@ -3421,18 +3422,79 @@ export class JMAPClient implements IJMAPClient {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve"];
|
||||
}
|
||||
|
||||
getSieveCapabilities(): SieveCapabilities | null {
|
||||
const sieveAccountId = this.getSieveAccountId();
|
||||
/**
|
||||
* Accounts (primary + shared/group) visible in this session, each tagged with
|
||||
* the capabilities it advertises. Unifies the per-feature enumeration that
|
||||
* getCalendarCapableAccountIds()/getContactCapableAccountIds()/
|
||||
* getFilesCapableAccountIds() do: a non-primary account is included when it
|
||||
* advertises a capability OR is a non-personal (shared/group) account, since
|
||||
* Stalwart doesn't always advertise capabilities on those even when they
|
||||
* support the feature. Primary account first; name from the session account
|
||||
* (primary falls back to the username). Drives the settings "Shared with me"
|
||||
* list and the scoped-settings tab gating.
|
||||
*/
|
||||
getSharedAccounts(): SharedAccount[] {
|
||||
const primaryId = this.getSieveAccountId();
|
||||
const toEntry = (id: string, isPrimary: boolean): SharedAccount => {
|
||||
const account = this.accounts[id];
|
||||
const caps = account?.accountCapabilities;
|
||||
const shared = !account?.isPersonal;
|
||||
return {
|
||||
id,
|
||||
name: account?.name || (isPrimary ? (this.username || id) : id),
|
||||
isPrimary,
|
||||
capabilities: {
|
||||
mail: !!caps?.["urn:ietf:params:jmap:mail"] || shared,
|
||||
sieve: !!caps?.["urn:ietf:params:jmap:sieve"] || shared,
|
||||
calendars: !!caps?.["urn:ietf:params:jmap:calendars"] || shared,
|
||||
contacts: !!caps?.["urn:ietf:params:jmap:contacts"] || shared,
|
||||
filenode: !!caps?.["urn:ietf:params:jmap:filenode"] || shared,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const result = [toEntry(primaryId, true)];
|
||||
for (const id of Object.keys(this.accounts)) {
|
||||
if (id === primaryId) continue;
|
||||
const account = this.accounts[id];
|
||||
// Mirror the per-feature helpers: include any non-personal account, plus
|
||||
// accounts that advertise at least one editable capability.
|
||||
const caps = account.accountCapabilities;
|
||||
const advertisesAny =
|
||||
!!caps?.["urn:ietf:params:jmap:mail"] ||
|
||||
!!caps?.["urn:ietf:params:jmap:sieve"] ||
|
||||
!!caps?.["urn:ietf:params:jmap:calendars"] ||
|
||||
!!caps?.["urn:ietf:params:jmap:contacts"] ||
|
||||
!!caps?.["urn:ietf:params:jmap:filenode"];
|
||||
if (!account.isPersonal || advertisesAny) {
|
||||
result.push(toEntry(id, false));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sieve-capable accounts (primary + shared/group), for the filters UI.
|
||||
* Thin wrapper over getSharedAccounts() filtered to the sieve capability.
|
||||
*/
|
||||
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] {
|
||||
return this.getSharedAccounts()
|
||||
.filter((a) => a.isPrimary || a.capabilities.sieve)
|
||||
.map(({ id, name, isPrimary }) => ({ id, name, isPrimary }));
|
||||
}
|
||||
|
||||
getSieveCapabilities(accountId?: string): SieveCapabilities | null {
|
||||
const sieveAccountId = accountId || this.getSieveAccountId();
|
||||
const accountInfo = this.accounts[sieveAccountId];
|
||||
if (!accountInfo?.accountCapabilities) return null;
|
||||
const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"];
|
||||
return (caps as SieveCapabilities) || null;
|
||||
}
|
||||
|
||||
async getSieveScripts(): Promise<SieveScript[]> {
|
||||
async getSieveScripts(accountId?: string): Promise<SieveScript[]> {
|
||||
const response = await this.request([
|
||||
["SieveScript/get", {
|
||||
accountId: this.getSieveAccountId(),
|
||||
accountId: accountId || this.getSieveAccountId(),
|
||||
}, "0"]
|
||||
], this.sieveUsing());
|
||||
|
||||
@@ -3442,21 +3504,26 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error('Failed to fetch Sieve scripts');
|
||||
}
|
||||
|
||||
async getSieveScriptContent(blobId: string): Promise<string> {
|
||||
const url = this.getBlobDownloadUrl(blobId, 'script.sieve', 'application/sieve');
|
||||
async getSieveScriptContent(blobId: string, accountId?: string): Promise<string> {
|
||||
// Blobs are scoped per account, so a shared/group account's script must be
|
||||
// downloaded against that owner's accountId, not the primary one.
|
||||
const url = this.getBlobDownloadUrl(
|
||||
blobId, 'script.sieve', 'application/sieve', accountId || this.getSieveAccountId(),
|
||||
);
|
||||
const response = await this.authenticatedFetch(url, {});
|
||||
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
private async uploadSieveBlob(content: string): Promise<string> {
|
||||
private async uploadSieveBlob(content: string, accountId?: string): Promise<string> {
|
||||
if (!this.session?.uploadUrl) {
|
||||
throw new Error('Upload URL not available');
|
||||
}
|
||||
|
||||
const targetAccountId = accountId || this.getSieveAccountId();
|
||||
const uploadUrl = this.session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(this.getSieveAccountId())
|
||||
encodeURIComponent(targetAccountId)
|
||||
);
|
||||
|
||||
const response = await this.authenticatedFetch(uploadUrl, {
|
||||
@@ -3474,17 +3541,17 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const result = await response.json();
|
||||
if (result.blobId) return result.blobId;
|
||||
const blobInfo = result[this.getSieveAccountId()];
|
||||
const blobInfo = result[targetAccountId];
|
||||
if (blobInfo?.blobId) return blobInfo.blobId;
|
||||
throw new Error('Invalid upload response: blobId not found');
|
||||
}
|
||||
|
||||
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
|
||||
const blobId = await this.uploadSieveBlob(content);
|
||||
const accountId = this.getSieveAccountId();
|
||||
async createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript> {
|
||||
const targetAccountId = accountId || this.getSieveAccountId();
|
||||
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
accountId: targetAccountId,
|
||||
create: {
|
||||
"new-script": { name, blobId }
|
||||
},
|
||||
@@ -3505,7 +3572,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
const createdId = result.created?.["new-script"]?.id;
|
||||
if (createdId) {
|
||||
const scripts = await this.getSieveScripts();
|
||||
const scripts = await this.getSieveScripts(targetAccountId);
|
||||
const script = scripts.find(s => s.id === createdId);
|
||||
if (script) return script;
|
||||
}
|
||||
@@ -3513,12 +3580,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error("Failed to create sieve script");
|
||||
}
|
||||
|
||||
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
|
||||
const blobId = await this.uploadSieveBlob(content);
|
||||
const accountId = this.getSieveAccountId();
|
||||
async updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void> {
|
||||
const targetAccountId = accountId || this.getSieveAccountId();
|
||||
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
accountId: targetAccountId,
|
||||
update: {
|
||||
[scriptId]: { blobId }
|
||||
},
|
||||
@@ -3542,12 +3609,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error("Failed to update sieve script");
|
||||
}
|
||||
|
||||
async deleteSieveScript(scriptId: string): Promise<void> {
|
||||
const accountId = this.getSieveAccountId();
|
||||
|
||||
async deleteSieveScript(scriptId: string, accountId?: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
["SieveScript/set", {
|
||||
accountId,
|
||||
accountId: accountId || this.getSieveAccountId(),
|
||||
destroy: [scriptId]
|
||||
}, "0"]
|
||||
], this.sieveUsing());
|
||||
@@ -3563,12 +3628,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error("Failed to delete sieve script");
|
||||
}
|
||||
|
||||
async activateSieveScript(scriptId: string): Promise<void> {
|
||||
const accountId = this.getSieveAccountId();
|
||||
|
||||
async activateSieveScript(scriptId: string, accountId?: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
["SieveScript/set", {
|
||||
accountId,
|
||||
accountId: accountId || this.getSieveAccountId(),
|
||||
onSuccessActivateScript: scriptId,
|
||||
}, "0"]
|
||||
], this.sieveUsing());
|
||||
@@ -3582,12 +3645,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async deactivateSieveScript(): Promise<void> {
|
||||
const accountId = this.getSieveAccountId();
|
||||
|
||||
async deactivateSieveScript(accountId?: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
["SieveScript/set", {
|
||||
accountId,
|
||||
accountId: accountId || this.getSieveAccountId(),
|
||||
onSuccessActivateScript: null,
|
||||
}, "0"]
|
||||
], this.sieveUsing());
|
||||
@@ -3601,13 +3662,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||
const blobId = await this.uploadSieveBlob(content);
|
||||
const accountId = this.getSieveAccountId();
|
||||
async validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||
const targetAccountId = accountId || this.getSieveAccountId();
|
||||
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||||
|
||||
const response = await this.request([
|
||||
["SieveScript/validate", {
|
||||
accountId,
|
||||
accountId: targetAccountId,
|
||||
blobId,
|
||||
}, "0"]
|
||||
], this.sieveUsing());
|
||||
|
||||
@@ -3,6 +3,25 @@ export interface EmailHeader {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A session-visible account (the user's own primary account plus any
|
||||
* shared/group accounts delegated to them), tagged with the capabilities it
|
||||
* advertises. Produced by client.getSharedAccounts(); drives the settings
|
||||
* "Shared with me" list and the scoped-settings tab gating.
|
||||
*/
|
||||
export interface SharedAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrimary: boolean;
|
||||
capabilities: {
|
||||
mail: boolean;
|
||||
sieve: boolean;
|
||||
calendars: boolean;
|
||||
contacts: boolean;
|
||||
filenode: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Email {
|
||||
id: string;
|
||||
threadId: string;
|
||||
|
||||
@@ -1289,8 +1289,17 @@
|
||||
"move_down": "Move down",
|
||||
"drag_handle": "Drag to reorder",
|
||||
"add": "Add account"
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Shared with me",
|
||||
"description": "Group and shared accounts you can manage. Select one to edit its filters, vacation responder, calendars, and contacts.",
|
||||
"shared_label": "Shared account"
|
||||
}
|
||||
},
|
||||
"scoped": {
|
||||
"back": "Back to my account",
|
||||
"managing": "Managing: {name}"
|
||||
},
|
||||
"security": {
|
||||
"title": "Account Security",
|
||||
"description": "Manage your password, two-factor authentication, and security settings",
|
||||
|
||||
@@ -3,6 +3,13 @@ import { useFilterStore } from '../filter-store';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Minimal account plumbing every fetchFilters mock needs now that the store
|
||||
// resolves a target Sieve account before fetching scripts.
|
||||
const sieveAccountMock = {
|
||||
getSieveAccountId: () => 'primary',
|
||||
getSieveAccounts: () => [{ id: 'primary', name: 'Me', isPrimary: true }],
|
||||
};
|
||||
|
||||
const makeRule = (overrides: Partial<FilterRule> = {}): FilterRule => ({
|
||||
id: 'rule-1',
|
||||
name: 'Test Rule',
|
||||
@@ -153,6 +160,7 @@ describe('filter-store', () => {
|
||||
describe('fetchFilters', () => {
|
||||
it('parses external rules from scripts without metadata', async () => {
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||
getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }',
|
||||
@@ -165,6 +173,7 @@ describe('filter-store', () => {
|
||||
|
||||
it('sets isOpaque for truly unparseable content', async () => {
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||
getSieveScriptContent: async () => '/* @metadata:begin\n{corrupt\n@metadata:end */',
|
||||
@@ -178,6 +187,7 @@ describe('filter-store', () => {
|
||||
const { generateScript } = await import('@/lib/sieve/generator');
|
||||
const script = generateScript(rules);
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||
getSieveScriptContent: async () => script,
|
||||
@@ -189,6 +199,7 @@ describe('filter-store', () => {
|
||||
|
||||
it('should handle empty script list', async () => {
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [],
|
||||
getSieveScriptContent: async () => '',
|
||||
@@ -200,6 +211,7 @@ describe('filter-store', () => {
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => { throw new Error('Network error'); },
|
||||
};
|
||||
@@ -313,6 +325,7 @@ describe('filter-store', () => {
|
||||
const inactiveRules = [makeRule({ name: 'Inactive' })];
|
||||
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [
|
||||
{ id: 's1', name: 'old', blobId: 'b1', isActive: false },
|
||||
@@ -331,6 +344,7 @@ describe('filter-store', () => {
|
||||
it('should set sieveCapabilities from client', async () => {
|
||||
const caps = { implementation: 'test', maxSizeScript: 10000, sieveExtensions: ['fileinto'], notificationMethods: [], externalLists: [] };
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => caps,
|
||||
getSieveScripts: async () => [],
|
||||
};
|
||||
@@ -343,6 +357,7 @@ describe('filter-store', () => {
|
||||
const rules = [makeRule()];
|
||||
const script = generateScript(rules);
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||
getSieveScriptContent: async () => script,
|
||||
@@ -357,6 +372,7 @@ describe('filter-store', () => {
|
||||
const script = generateScript(rules);
|
||||
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [
|
||||
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
|
||||
@@ -375,6 +391,7 @@ describe('filter-store', () => {
|
||||
|
||||
it('should handle only vacation script present (no filter scripts)', async () => {
|
||||
const mockClient = {
|
||||
...sieveAccountMock,
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [
|
||||
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
|
||||
@@ -384,5 +401,72 @@ describe('filter-store', () => {
|
||||
expect(useFilterStore.getState().activeScriptId).toBeNull();
|
||||
expect(useFilterStore.getState().rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('populates availableAccounts and defaults to the primary account', async () => {
|
||||
const mockClient = {
|
||||
getSieveAccountId: () => 'primary',
|
||||
getSieveAccounts: () => [
|
||||
{ id: 'primary', name: 'Me', isPrimary: true },
|
||||
{ id: 'group', name: 'Sales', isPrimary: false },
|
||||
],
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [],
|
||||
};
|
||||
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
|
||||
expect(useFilterStore.getState().availableAccounts).toHaveLength(2);
|
||||
expect(useFilterStore.getState().selectedAccountId).toBe('primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectAccount', () => {
|
||||
it('re-fetches scripts for the chosen account id', async () => {
|
||||
const fetchedFor: (string | undefined)[] = [];
|
||||
const mockClient = {
|
||||
getSieveAccountId: () => 'primary',
|
||||
getSieveAccounts: () => [
|
||||
{ id: 'primary', name: 'Me', isPrimary: true },
|
||||
{ id: 'group', name: 'Sales', isPrimary: false },
|
||||
],
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async (accountId?: string) => {
|
||||
fetchedFor.push(accountId);
|
||||
return [];
|
||||
},
|
||||
};
|
||||
// Seed some primary-account state to confirm it gets cleared on switch.
|
||||
useFilterStore.setState({ rules: [makeRule()], activeScriptId: 'old', isOpaque: true });
|
||||
|
||||
await useFilterStore.getState().selectAccount(mockClient as unknown as IJMAPClient, 'group');
|
||||
|
||||
expect(useFilterStore.getState().selectedAccountId).toBe('group');
|
||||
expect(fetchedFor).toContain('group');
|
||||
expect(useFilterStore.getState().rules).toEqual([]);
|
||||
expect(useFilterStore.getState().isOpaque).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('account threading', () => {
|
||||
it('passes the selected account id to update and validate', async () => {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = [];
|
||||
const mockClient = {
|
||||
updateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'updateSieveScript', args }); },
|
||||
createSieveScript: async (...args: unknown[]) => { calls.push({ method: 'createSieveScript', args }); return { id: 'x' }; },
|
||||
validateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'validateSieveScript', args }); return { isValid: true }; },
|
||||
};
|
||||
useFilterStore.setState({
|
||||
activeScriptId: 'existing-id',
|
||||
rules: [makeRule()],
|
||||
isOpaque: false,
|
||||
selectedAccountId: 'group',
|
||||
});
|
||||
|
||||
await useFilterStore.getState().saveFilters(mockClient as unknown as IJMAPClient);
|
||||
await useFilterStore.getState().validateScript(mockClient as unknown as IJMAPClient, 'require "fileinto";');
|
||||
|
||||
const update = calls.find(c => c.method === 'updateSieveScript');
|
||||
const validate = calls.find(c => c.method === 'validateSieveScript');
|
||||
expect(update?.args[3]).toBe('group');
|
||||
expect(validate?.args[1]).toBe('group');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useManagedAccountStore } from '../managed-account-store';
|
||||
import type { SharedAccount } from '@/lib/jmap/types';
|
||||
|
||||
const sharedAccount: SharedAccount = {
|
||||
id: 'group-1',
|
||||
name: 'Sales',
|
||||
isPrimary: false,
|
||||
capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: false },
|
||||
};
|
||||
|
||||
describe('managed-account-store', () => {
|
||||
beforeEach(() => {
|
||||
useManagedAccountStore.getState().clear();
|
||||
});
|
||||
|
||||
it('defaults to no managed account (own account)', () => {
|
||||
expect(useManagedAccountStore.getState().managedAccountId).toBeNull();
|
||||
expect(useManagedAccountStore.getState().managedAccount).toBeNull();
|
||||
});
|
||||
|
||||
it('setManagedAccount enters scoped mode for the account', () => {
|
||||
useManagedAccountStore.getState().setManagedAccount(sharedAccount);
|
||||
expect(useManagedAccountStore.getState().managedAccountId).toBe('group-1');
|
||||
expect(useManagedAccountStore.getState().managedAccount).toEqual(sharedAccount);
|
||||
});
|
||||
|
||||
it('setManagedAccount(null) and clear() return to the own account', () => {
|
||||
useManagedAccountStore.getState().setManagedAccount(sharedAccount);
|
||||
useManagedAccountStore.getState().setManagedAccount(null);
|
||||
expect(useManagedAccountStore.getState().managedAccountId).toBeNull();
|
||||
expect(useManagedAccountStore.getState().managedAccount).toBeNull();
|
||||
|
||||
useManagedAccountStore.getState().setManagedAccount(sharedAccount);
|
||||
useManagedAccountStore.getState().clear();
|
||||
expect(useManagedAccountStore.getState().managedAccountId).toBeNull();
|
||||
});
|
||||
});
|
||||
+42
-9
@@ -5,6 +5,12 @@ import { parseScript } from '@/lib/sieve/parser';
|
||||
import { generateScript } from '@/lib/sieve/generator';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
interface SieveAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
interface FilterStore {
|
||||
rules: FilterRule[];
|
||||
isLoading: boolean;
|
||||
@@ -17,9 +23,12 @@ interface FilterStore {
|
||||
rawScript: string;
|
||||
vacationSettings: VacationSieveConfig | null;
|
||||
externalRequires: string[];
|
||||
availableAccounts: SieveAccount[];
|
||||
selectedAccountId: string | null;
|
||||
|
||||
setSupported: (supported: boolean) => void;
|
||||
fetchFilters: (client: IJMAPClient) => Promise<void>;
|
||||
fetchFilters: (client: IJMAPClient, accountId?: string) => Promise<void>;
|
||||
selectAccount: (client: IJMAPClient, accountId: string) => Promise<void>;
|
||||
saveFilters: (client: IJMAPClient) => Promise<void>;
|
||||
validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
|
||||
addRule: (rule: FilterRule) => void;
|
||||
@@ -44,16 +53,23 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
rawScript: '',
|
||||
vacationSettings: null,
|
||||
externalRequires: [],
|
||||
availableAccounts: [],
|
||||
selectedAccountId: null,
|
||||
|
||||
setSupported: (supported) => set({ isSupported: supported }),
|
||||
|
||||
fetchFilters: async (client) => {
|
||||
fetchFilters: async (client, accountId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const capabilities = client.getSieveCapabilities();
|
||||
const accounts = client.getSieveAccounts();
|
||||
const resolvedId =
|
||||
accountId || get().selectedAccountId || client.getSieveAccountId();
|
||||
set({ availableAccounts: accounts, selectedAccountId: resolvedId });
|
||||
|
||||
const capabilities = client.getSieveCapabilities(resolvedId);
|
||||
set({ sieveCapabilities: capabilities });
|
||||
|
||||
const allScripts = await client.getSieveScripts();
|
||||
const allScripts = await client.getSieveScripts(resolvedId);
|
||||
debug.log('filters', 'Sieve scripts fetched:', allScripts.length);
|
||||
|
||||
// Skip the server-managed 'vacation' script (RFC 9661 §4) - it can only
|
||||
@@ -68,7 +84,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
|
||||
set({ activeScriptId: activeScript.id });
|
||||
|
||||
const content = await client.getSieveScriptContent(activeScript.blobId);
|
||||
const content = await client.getSieveScriptContent(activeScript.blobId, resolvedId);
|
||||
set({ rawScript: content });
|
||||
|
||||
const result = parseScript(content);
|
||||
@@ -101,10 +117,25 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
selectAccount: async (client, accountId) => {
|
||||
// Reset parsed state so one account's rules/script never leak into another
|
||||
// before the re-fetch populates the new account's data.
|
||||
set({
|
||||
selectedAccountId: accountId,
|
||||
rules: [],
|
||||
rawScript: '',
|
||||
activeScriptId: null,
|
||||
isOpaque: false,
|
||||
vacationSettings: null,
|
||||
externalRequires: [],
|
||||
});
|
||||
await get().fetchFilters(client, accountId);
|
||||
},
|
||||
|
||||
saveFilters: async (client) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires } = get();
|
||||
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, selectedAccountId } = get();
|
||||
|
||||
let content: string;
|
||||
if (isOpaque) {
|
||||
@@ -114,9 +145,9 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
}
|
||||
|
||||
if (activeScriptId) {
|
||||
await client.updateSieveScript(activeScriptId, content, true);
|
||||
await client.updateSieveScript(activeScriptId, content, true, selectedAccountId || undefined);
|
||||
} else {
|
||||
const script = await client.createSieveScript('filters', content, true);
|
||||
const script = await client.createSieveScript('filters', content, true, selectedAccountId || undefined);
|
||||
set({ activeScriptId: script.id });
|
||||
}
|
||||
|
||||
@@ -133,7 +164,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
},
|
||||
|
||||
validateScript: async (client, content) => {
|
||||
return client.validateSieveScript(content);
|
||||
return client.validateSieveScript(content, get().selectedAccountId || undefined);
|
||||
},
|
||||
|
||||
addRule: (rule) => {
|
||||
@@ -204,5 +235,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
rawScript: '',
|
||||
vacationSettings: null,
|
||||
externalRequires: [],
|
||||
availableAccounts: [],
|
||||
selectedAccountId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SharedAccount } from '@/lib/jmap/types';
|
||||
|
||||
/**
|
||||
* Tracks which account the settings panel is currently scoped to. `null` means
|
||||
* the user's own (primary) account — the default, full settings view. When set
|
||||
* to a shared/group account, the settings panel enters "scoped mode": a reduced
|
||||
* tab list and a "Managing: <name>" header, and the account-scoped settings
|
||||
* pages (filters, vacation, calendars, contacts) read `managedAccountId` to
|
||||
* target that account.
|
||||
*
|
||||
* This is session-only navigation state (not persisted) so a shared-account
|
||||
* context never leaks across reloads or logout.
|
||||
*/
|
||||
interface ManagedAccountStore {
|
||||
managedAccountId: string | null;
|
||||
managedAccount: SharedAccount | null;
|
||||
|
||||
/** Enter scoped mode for `account`, or pass `null` to return to own account. */
|
||||
setManagedAccount: (account: SharedAccount | null) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useManagedAccountStore = create<ManagedAccountStore>()((set) => ({
|
||||
managedAccountId: null,
|
||||
managedAccount: null,
|
||||
|
||||
setManagedAccount: (account) =>
|
||||
set({ managedAccountId: account?.id ?? null, managedAccount: account }),
|
||||
|
||||
clear: () => set({ managedAccountId: null, managedAccount: null }),
|
||||
}));
|
||||
@@ -13,7 +13,7 @@ interface VacationStore {
|
||||
error: string | null;
|
||||
isSupported: boolean;
|
||||
|
||||
fetchVacationResponse: (client: IJMAPClient) => Promise<void>;
|
||||
fetchVacationResponse: (client: IJMAPClient, accountId?: string) => Promise<void>;
|
||||
updateVacationResponse: (client: IJMAPClient, updates: {
|
||||
isEnabled?: boolean;
|
||||
fromDate?: string | null;
|
||||
@@ -21,7 +21,7 @@ interface VacationStore {
|
||||
subject?: string;
|
||||
textBody?: string;
|
||||
htmlBody?: string | null;
|
||||
}) => Promise<void>;
|
||||
}, accountId?: string) => Promise<void>;
|
||||
setSupported: (supported: boolean) => void;
|
||||
clearState: () => void;
|
||||
}
|
||||
@@ -38,10 +38,10 @@ export const useVacationStore = create<VacationStore>()((set) => ({
|
||||
error: null,
|
||||
isSupported: false,
|
||||
|
||||
fetchVacationResponse: async (client) => {
|
||||
fetchVacationResponse: async (client, accountId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const vacation = await client.getVacationResponse();
|
||||
const vacation = await client.getVacationResponse(accountId);
|
||||
set({
|
||||
isEnabled: vacation.isEnabled,
|
||||
fromDate: vacation.fromDate,
|
||||
@@ -59,10 +59,10 @@ export const useVacationStore = create<VacationStore>()((set) => ({
|
||||
}
|
||||
},
|
||||
|
||||
updateVacationResponse: async (client, updates) => {
|
||||
updateVacationResponse: async (client, updates, accountId) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
await client.setVacationResponse(updates);
|
||||
await client.setVacationResponse(updates, accountId);
|
||||
set((state) => ({
|
||||
...state,
|
||||
...updates,
|
||||
|
||||
Reference in New Issue
Block a user