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 { usePluginStore } from '@/stores/plugin-store';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||||
@@ -385,6 +386,12 @@ export default function SettingsPage() {
|
|||||||
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
||||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
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
|
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||||
// (individual settings) per tab. Sub-results come from translation entries
|
// (individual settings) per tab. Sub-results come from translation entries
|
||||||
// that have a `label`/`title` field, plus dynamic content (installed
|
// 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);
|
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(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
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 }] : []),
|
...(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
|
// Group tabs by category
|
||||||
const groupedTabs = tabGroupOrder
|
const groupedTabs = tabGroupOrder
|
||||||
.map((group) => ({
|
.map((group) => ({
|
||||||
group,
|
group,
|
||||||
label: t(`tab_groups.${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);
|
.filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
@@ -659,9 +686,13 @@ export default function SettingsPage() {
|
|||||||
.filter((g) => g.items.length > 0)
|
.filter((g) => g.items.length > 0)
|
||||||
: groupedTabs;
|
: groupedTabs;
|
||||||
|
|
||||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
// If active tab is not in the visible list (e.g., feature disabled, or scoped
|
||||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
// 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) => {
|
const handleTabSelect = (tabId: Tab) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
@@ -676,10 +707,26 @@ export default function SettingsPage() {
|
|||||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
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 = () => (
|
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 === 'account' && <AccountSettings />}
|
||||||
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||||
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||||
@@ -697,8 +744,16 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
{effectiveActiveTab === 'calendar' && (
|
||||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
managedAccountId
|
||||||
|
? <CalendarManagementSettings />
|
||||||
|
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
|
||||||
|
)}
|
||||||
|
{effectiveActiveTab === 'contacts' && (
|
||||||
|
managedAccountId
|
||||||
|
? <AddressBookManagementSettings />
|
||||||
|
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
|
||||||
|
)}
|
||||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
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 { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { useAccountStore, type AccountEntry } from '@/stores/account-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 { SettingsSection, SettingItem } from './settings-section';
|
||||||
import { Avatar } from '@/components/ui/avatar';
|
import { Avatar } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -17,12 +19,30 @@ function hostnameOf(serverUrl: string): string {
|
|||||||
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
|
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() {
|
export function AccountSettings() {
|
||||||
const t = useTranslations('settings.account');
|
const t = useTranslations('settings.account');
|
||||||
const router = useRouter();
|
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 activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
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 { quota } = useEmailStore();
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||||
@@ -82,6 +102,16 @@ export function AccountSettings() {
|
|||||||
router.push(`/login?mode=add-account` as never);
|
router.push(`/login?mode=add-account` as never);
|
||||||
}, [router]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
@@ -197,6 +227,44 @@ export function AccountSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
|
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -71,6 +72,7 @@ export function AddressBookManagementSettings() {
|
|||||||
const tContacts = useTranslations("contacts");
|
const tContacts = useTranslations("contacts");
|
||||||
const tSettings = useTranslations("settings.contacts");
|
const tSettings = useTranslations("settings.contacts");
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
|
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editingKeyword, setEditingKeyword] = 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")}>
|
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
|
||||||
<div className="space-y-2">
|
<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">
|
<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">
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||||
<Share2 className="w-3 h-3" />
|
<Share2 className="w-3 h-3" />
|
||||||
@@ -223,6 +229,9 @@ export function AddressBookManagementSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</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">
|
<div className="mt-8">
|
||||||
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
|
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -267,6 +276,7 @@ export function AddressBookManagementSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{sharingId && client && (() => {
|
{sharingId && client && (() => {
|
||||||
const book = addressBooks.find((b) => b.id === sharingId);
|
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 { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
|
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
|
|||||||
export function CalendarManagementSettings() {
|
export function CalendarManagementSettings() {
|
||||||
const t = useTranslations('calendar.management');
|
const t = useTranslations('calendar.management');
|
||||||
const { client, serverUrl, username } = useAuthStore();
|
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();
|
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||||
// Subscriptions are persisted globally but scoped per JMAP account via
|
// Subscriptions are persisted globally but scoped per JMAP account via
|
||||||
// accountId. Legacy entries with no accountId show in the active account.
|
// accountId. Legacy entries with no accountId show in the active account.
|
||||||
@@ -388,7 +390,10 @@ export function CalendarManagementSettings() {
|
|||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
<div className="space-y-2">
|
<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';
|
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
|
||||||
|
|
||||||
if (editingId === cal.id) {
|
if (editingId === cal.id) {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { useEmailStore } from "@/stores/email-store";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import type { FilterRule } from "@/lib/jmap/sieve-types";
|
import type { FilterRule } from "@/lib/jmap/sieve-types";
|
||||||
|
import type { Mailbox } from "@/lib/jmap/types";
|
||||||
import { useVacationStore } from "@/stores/vacation-store";
|
import { useVacationStore } from "@/stores/vacation-store";
|
||||||
|
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
@@ -157,7 +159,7 @@ export function FilterSettings() {
|
|||||||
const t = useTranslations("settings.filters");
|
const t = useTranslations("settings.filters");
|
||||||
const tNotifications = useTranslations("notifications");
|
const tNotifications = useTranslations("notifications");
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
const storeMailboxes = useEmailStore((s) => s.mailboxes);
|
||||||
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
|
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
|
||||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||||
|
|
||||||
@@ -170,7 +172,7 @@ export function FilterSettings() {
|
|||||||
isOpaque,
|
isOpaque,
|
||||||
rawScript,
|
rawScript,
|
||||||
vacationSettings,
|
vacationSettings,
|
||||||
fetchFilters,
|
selectAccount,
|
||||||
saveFilters,
|
saveFilters,
|
||||||
addRule,
|
addRule,
|
||||||
updateRule,
|
updateRule,
|
||||||
@@ -182,7 +184,41 @@ export function FilterSettings() {
|
|||||||
validateScript,
|
validateScript,
|
||||||
} = useFilterStore();
|
} = 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 [editingRule, setEditingRule] = useState<FilterRule | undefined>();
|
||||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||||
@@ -194,9 +230,12 @@ export function FilterSettings() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && isSupported) {
|
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(
|
const handleToggle = useCallback(
|
||||||
async (ruleId: string) => {
|
async (ruleId: string) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useVacationStore } from '@/stores/vacation-store';
|
import { useVacationStore } from '@/stores/vacation-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||||
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
|
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function VacationSettings() {
|
|||||||
const t = useTranslations('settings.vacation');
|
const t = useTranslations('settings.vacation');
|
||||||
const tNotifications = useTranslations('notifications');
|
const tNotifications = useTranslations('notifications');
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
|
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||||
const {
|
const {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
fromDate,
|
fromDate,
|
||||||
@@ -43,9 +45,9 @@ export function VacationSettings() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && isSupported) {
|
if (client && isSupported) {
|
||||||
void fetchVacationResponse(client);
|
void fetchVacationResponse(client, managedAccountId ?? undefined);
|
||||||
}
|
}
|
||||||
}, [client, isSupported, fetchVacationResponse]);
|
}, [client, isSupported, managedAccountId, fetchVacationResponse]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalEnabled(isEnabled);
|
setLocalEnabled(isEnabled);
|
||||||
@@ -101,7 +103,7 @@ export function VacationSettings() {
|
|||||||
toDate: localToDate || null,
|
toDate: localToDate || null,
|
||||||
subject: localSubject,
|
subject: localSubject,
|
||||||
textBody: localTextBody,
|
textBody: localTextBody,
|
||||||
});
|
}, managedAccountId ?? undefined);
|
||||||
|
|
||||||
toast.success(tNotifications('vacation_saved'));
|
toast.success(tNotifications('vacation_saved'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+26
-11
@@ -1,5 +1,5 @@
|
|||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
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 type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
import { getDemoData, type DemoData } from './demo-data';
|
import { getDemoData, type DemoData } from './demo-data';
|
||||||
import { generateDemoId } from './demo-utils';
|
import { generateDemoId } from './demo-utils';
|
||||||
@@ -118,7 +118,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
// ── Mailboxes ─────────────────────────────────────────────────
|
// ── 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 getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||||
|
|
||||||
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
|
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
|
||||||
@@ -603,9 +603,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
// ── Vacation ──────────────────────────────────────────────────
|
// ── 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);
|
Object.assign(this.data.vacationResponse, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,19 +849,34 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
// ── Sieve / Filters ──────────────────────────────────────────
|
// ── 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'; }
|
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 };
|
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] ?? '';
|
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 blobId = generateDemoId('sieve-blob');
|
||||||
const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false };
|
const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false };
|
||||||
this.data.sieveScripts.push(script);
|
this.data.sieveScripts.push(script);
|
||||||
@@ -874,7 +889,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return script;
|
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);
|
const script = this.data.sieveScripts.find(s => s.id === scriptId);
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
const blobId = generateDemoId('sieve-blob');
|
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);
|
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 };
|
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";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,7 +71,7 @@ export interface IJMAPClient {
|
|||||||
getQuota(): Promise<{ used: number; total: number } | null>;
|
getQuota(): Promise<{ used: number; total: number } | null>;
|
||||||
|
|
||||||
// ── Mailboxes ─────────────────────────────────────────────────
|
// ── Mailboxes ─────────────────────────────────────────────────
|
||||||
getMailboxes(): Promise<Mailbox[]>;
|
getMailboxes(accountId?: string): Promise<Mailbox[]>;
|
||||||
getAllMailboxes(): Promise<Mailbox[]>;
|
getAllMailboxes(): Promise<Mailbox[]>;
|
||||||
createMailbox(name: string, parentId?: string): 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>;
|
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>;
|
deleteIdentity(identityId: string): Promise<void>;
|
||||||
|
|
||||||
// ── Vacation ──────────────────────────────────────────────────
|
// ── Vacation ──────────────────────────────────────────────────
|
||||||
getVacationResponse(): Promise<VacationResponse>;
|
getVacationResponse(accountId?: string): Promise<VacationResponse>;
|
||||||
setVacationResponse(updates: Partial<VacationResponse>): Promise<void>;
|
setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void>;
|
||||||
|
|
||||||
// ── Contacts ──────────────────────────────────────────────────
|
// ── Contacts ──────────────────────────────────────────────────
|
||||||
getContactsAccountId(): string;
|
getContactsAccountId(): string;
|
||||||
@@ -294,15 +294,19 @@ export interface IJMAPClient {
|
|||||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||||
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | 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 ──────────────────────────────────────────
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
getSieveAccountId(): string;
|
getSieveAccountId(): string;
|
||||||
getSieveCapabilities(): SieveCapabilities | null;
|
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[];
|
||||||
getSieveScripts(): Promise<SieveScript[]>;
|
getSieveCapabilities(accountId?: string): SieveCapabilities | null;
|
||||||
getSieveScriptContent(blobId: string): Promise<string>;
|
getSieveScripts(accountId?: string): Promise<SieveScript[]>;
|
||||||
createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript>;
|
getSieveScriptContent(blobId: string, accountId?: string): Promise<string>;
|
||||||
updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void>;
|
createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript>;
|
||||||
deleteSieveScript(scriptId: string): Promise<void>;
|
updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void>;
|
||||||
validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>;
|
deleteSieveScript(scriptId: string, accountId?: string): Promise<void>;
|
||||||
|
validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }>;
|
||||||
|
|
||||||
// ── Files (WebDAV / FileNode) ─────────────────────────────────
|
// ── Files (WebDAV / FileNode) ─────────────────────────────────
|
||||||
getFilesAccountId(): string;
|
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 { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
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 {
|
try {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Mailbox/get", { accountId: this.accountId }, "0"]
|
["Mailbox/get", { accountId: acctId }, "0"]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
||||||
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
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
|
// Warn if response might be truncated
|
||||||
const maxObjects = this.getMaxObjectsInGet();
|
const maxObjects = this.getMaxObjectsInGet();
|
||||||
@@ -940,9 +941,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
unreadThreads: mb.unreadThreads ?? 0,
|
unreadThreads: mb.unreadThreads ?? 0,
|
||||||
myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS,
|
myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS,
|
||||||
isSubscribed: mb.isSubscribed ?? true,
|
isSubscribed: mb.isSubscribed ?? true,
|
||||||
accountId: this.accountId,
|
accountId: acctId,
|
||||||
accountName: this.accounts[this.accountId]?.name || this.username,
|
accountName: this.accounts[acctId]?.name || this.username,
|
||||||
isShared: false,
|
isShared: acctId !== this.accountId,
|
||||||
}) as Mailbox);
|
}) 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"];
|
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([
|
const response = await this.request([
|
||||||
["VacationResponse/get", {
|
["VacationResponse/get", {
|
||||||
accountId: this.accountId,
|
accountId: accountId || this.accountId,
|
||||||
ids: ["singleton"],
|
ids: ["singleton"],
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.vacationUsing());
|
], this.vacationUsing());
|
||||||
@@ -2109,10 +2110,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error("Failed to fetch vacation response: unexpected server response");
|
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([
|
const response = await this.request([
|
||||||
["VacationResponse/set", {
|
["VacationResponse/set", {
|
||||||
accountId: this.accountId,
|
accountId: accountId || this.accountId,
|
||||||
update: {
|
update: {
|
||||||
"singleton": updates,
|
"singleton": updates,
|
||||||
},
|
},
|
||||||
@@ -3421,18 +3422,79 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve"];
|
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];
|
const accountInfo = this.accounts[sieveAccountId];
|
||||||
if (!accountInfo?.accountCapabilities) return null;
|
if (!accountInfo?.accountCapabilities) return null;
|
||||||
const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"];
|
const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"];
|
||||||
return (caps as SieveCapabilities) || null;
|
return (caps as SieveCapabilities) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSieveScripts(): Promise<SieveScript[]> {
|
async getSieveScripts(accountId?: string): Promise<SieveScript[]> {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["SieveScript/get", {
|
["SieveScript/get", {
|
||||||
accountId: this.getSieveAccountId(),
|
accountId: accountId || this.getSieveAccountId(),
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.sieveUsing());
|
], this.sieveUsing());
|
||||||
|
|
||||||
@@ -3442,21 +3504,26 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error('Failed to fetch Sieve scripts');
|
throw new Error('Failed to fetch Sieve scripts');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSieveScriptContent(blobId: string): Promise<string> {
|
async getSieveScriptContent(blobId: string, accountId?: string): Promise<string> {
|
||||||
const url = this.getBlobDownloadUrl(blobId, 'script.sieve', 'application/sieve');
|
// 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, {});
|
const response = await this.authenticatedFetch(url, {});
|
||||||
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
|
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
|
||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadSieveBlob(content: string): Promise<string> {
|
private async uploadSieveBlob(content: string, accountId?: string): Promise<string> {
|
||||||
if (!this.session?.uploadUrl) {
|
if (!this.session?.uploadUrl) {
|
||||||
throw new Error('Upload URL not available');
|
throw new Error('Upload URL not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetAccountId = accountId || this.getSieveAccountId();
|
||||||
const uploadUrl = this.session.uploadUrl.replace(
|
const uploadUrl = this.session.uploadUrl.replace(
|
||||||
'{accountId}',
|
'{accountId}',
|
||||||
encodeURIComponent(this.getSieveAccountId())
|
encodeURIComponent(targetAccountId)
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.authenticatedFetch(uploadUrl, {
|
const response = await this.authenticatedFetch(uploadUrl, {
|
||||||
@@ -3474,17 +3541,17 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.blobId) return result.blobId;
|
if (result.blobId) return result.blobId;
|
||||||
const blobInfo = result[this.getSieveAccountId()];
|
const blobInfo = result[targetAccountId];
|
||||||
if (blobInfo?.blobId) return blobInfo.blobId;
|
if (blobInfo?.blobId) return blobInfo.blobId;
|
||||||
throw new Error('Invalid upload response: blobId not found');
|
throw new Error('Invalid upload response: blobId not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
|
async createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript> {
|
||||||
const blobId = await this.uploadSieveBlob(content);
|
const targetAccountId = accountId || this.getSieveAccountId();
|
||||||
const accountId = this.getSieveAccountId();
|
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId: targetAccountId,
|
||||||
create: {
|
create: {
|
||||||
"new-script": { name, blobId }
|
"new-script": { name, blobId }
|
||||||
},
|
},
|
||||||
@@ -3505,7 +3572,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
const createdId = result.created?.["new-script"]?.id;
|
const createdId = result.created?.["new-script"]?.id;
|
||||||
if (createdId) {
|
if (createdId) {
|
||||||
const scripts = await this.getSieveScripts();
|
const scripts = await this.getSieveScripts(targetAccountId);
|
||||||
const script = scripts.find(s => s.id === createdId);
|
const script = scripts.find(s => s.id === createdId);
|
||||||
if (script) return script;
|
if (script) return script;
|
||||||
}
|
}
|
||||||
@@ -3513,12 +3580,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error("Failed to create sieve script");
|
throw new Error("Failed to create sieve script");
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
|
async updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void> {
|
||||||
const blobId = await this.uploadSieveBlob(content);
|
const targetAccountId = accountId || this.getSieveAccountId();
|
||||||
const accountId = this.getSieveAccountId();
|
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId: targetAccountId,
|
||||||
update: {
|
update: {
|
||||||
[scriptId]: { blobId }
|
[scriptId]: { blobId }
|
||||||
},
|
},
|
||||||
@@ -3542,12 +3609,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error("Failed to update sieve script");
|
throw new Error("Failed to update sieve script");
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSieveScript(scriptId: string): Promise<void> {
|
async deleteSieveScript(scriptId: string, accountId?: string): Promise<void> {
|
||||||
const accountId = this.getSieveAccountId();
|
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["SieveScript/set", {
|
["SieveScript/set", {
|
||||||
accountId,
|
accountId: accountId || this.getSieveAccountId(),
|
||||||
destroy: [scriptId]
|
destroy: [scriptId]
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.sieveUsing());
|
], this.sieveUsing());
|
||||||
@@ -3563,12 +3628,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error("Failed to delete sieve script");
|
throw new Error("Failed to delete sieve script");
|
||||||
}
|
}
|
||||||
|
|
||||||
async activateSieveScript(scriptId: string): Promise<void> {
|
async activateSieveScript(scriptId: string, accountId?: string): Promise<void> {
|
||||||
const accountId = this.getSieveAccountId();
|
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["SieveScript/set", {
|
["SieveScript/set", {
|
||||||
accountId,
|
accountId: accountId || this.getSieveAccountId(),
|
||||||
onSuccessActivateScript: scriptId,
|
onSuccessActivateScript: scriptId,
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.sieveUsing());
|
], this.sieveUsing());
|
||||||
@@ -3582,12 +3645,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deactivateSieveScript(): Promise<void> {
|
async deactivateSieveScript(accountId?: string): Promise<void> {
|
||||||
const accountId = this.getSieveAccountId();
|
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["SieveScript/set", {
|
["SieveScript/set", {
|
||||||
accountId,
|
accountId: accountId || this.getSieveAccountId(),
|
||||||
onSuccessActivateScript: null,
|
onSuccessActivateScript: null,
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.sieveUsing());
|
], this.sieveUsing());
|
||||||
@@ -3601,13 +3662,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> {
|
async validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||||
const blobId = await this.uploadSieveBlob(content);
|
const targetAccountId = accountId || this.getSieveAccountId();
|
||||||
const accountId = this.getSieveAccountId();
|
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["SieveScript/validate", {
|
["SieveScript/validate", {
|
||||||
accountId,
|
accountId: targetAccountId,
|
||||||
blobId,
|
blobId,
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.sieveUsing());
|
], this.sieveUsing());
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ export interface EmailHeader {
|
|||||||
value: string;
|
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 {
|
export interface Email {
|
||||||
id: string;
|
id: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
|||||||
@@ -1289,8 +1289,17 @@
|
|||||||
"move_down": "Move down",
|
"move_down": "Move down",
|
||||||
"drag_handle": "Drag to reorder",
|
"drag_handle": "Drag to reorder",
|
||||||
"add": "Add account"
|
"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": {
|
"security": {
|
||||||
"title": "Account Security",
|
"title": "Account Security",
|
||||||
"description": "Manage your password, two-factor authentication, and security settings",
|
"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 { FilterRule } from '@/lib/jmap/sieve-types';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
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 => ({
|
const makeRule = (overrides: Partial<FilterRule> = {}): FilterRule => ({
|
||||||
id: 'rule-1',
|
id: 'rule-1',
|
||||||
name: 'Test Rule',
|
name: 'Test Rule',
|
||||||
@@ -153,6 +160,7 @@ describe('filter-store', () => {
|
|||||||
describe('fetchFilters', () => {
|
describe('fetchFilters', () => {
|
||||||
it('parses external rules from scripts without metadata', async () => {
|
it('parses external rules from scripts without metadata', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||||
getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }',
|
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 () => {
|
it('sets isOpaque for truly unparseable content', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||||
getSieveScriptContent: async () => '/* @metadata:begin\n{corrupt\n@metadata:end */',
|
getSieveScriptContent: async () => '/* @metadata:begin\n{corrupt\n@metadata:end */',
|
||||||
@@ -178,6 +187,7 @@ describe('filter-store', () => {
|
|||||||
const { generateScript } = await import('@/lib/sieve/generator');
|
const { generateScript } = await import('@/lib/sieve/generator');
|
||||||
const script = generateScript(rules);
|
const script = generateScript(rules);
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||||
getSieveScriptContent: async () => script,
|
getSieveScriptContent: async () => script,
|
||||||
@@ -189,6 +199,7 @@ describe('filter-store', () => {
|
|||||||
|
|
||||||
it('should handle empty script list', async () => {
|
it('should handle empty script list', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => [],
|
getSieveScripts: async () => [],
|
||||||
getSieveScriptContent: async () => '',
|
getSieveScriptContent: async () => '',
|
||||||
@@ -200,6 +211,7 @@ describe('filter-store', () => {
|
|||||||
|
|
||||||
it('should set error on failure', async () => {
|
it('should set error on failure', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => { throw new Error('Network error'); },
|
getSieveScripts: async () => { throw new Error('Network error'); },
|
||||||
};
|
};
|
||||||
@@ -313,6 +325,7 @@ describe('filter-store', () => {
|
|||||||
const inactiveRules = [makeRule({ name: 'Inactive' })];
|
const inactiveRules = [makeRule({ name: 'Inactive' })];
|
||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => [
|
getSieveScripts: async () => [
|
||||||
{ id: 's1', name: 'old', blobId: 'b1', isActive: false },
|
{ id: 's1', name: 'old', blobId: 'b1', isActive: false },
|
||||||
@@ -331,6 +344,7 @@ describe('filter-store', () => {
|
|||||||
it('should set sieveCapabilities from client', async () => {
|
it('should set sieveCapabilities from client', async () => {
|
||||||
const caps = { implementation: 'test', maxSizeScript: 10000, sieveExtensions: ['fileinto'], notificationMethods: [], externalLists: [] };
|
const caps = { implementation: 'test', maxSizeScript: 10000, sieveExtensions: ['fileinto'], notificationMethods: [], externalLists: [] };
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => caps,
|
getSieveCapabilities: () => caps,
|
||||||
getSieveScripts: async () => [],
|
getSieveScripts: async () => [],
|
||||||
};
|
};
|
||||||
@@ -343,6 +357,7 @@ describe('filter-store', () => {
|
|||||||
const rules = [makeRule()];
|
const rules = [makeRule()];
|
||||||
const script = generateScript(rules);
|
const script = generateScript(rules);
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||||
getSieveScriptContent: async () => script,
|
getSieveScriptContent: async () => script,
|
||||||
@@ -357,6 +372,7 @@ describe('filter-store', () => {
|
|||||||
const script = generateScript(rules);
|
const script = generateScript(rules);
|
||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => [
|
getSieveScripts: async () => [
|
||||||
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
|
{ 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 () => {
|
it('should handle only vacation script present (no filter scripts)', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
...sieveAccountMock,
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => [
|
getSieveScripts: async () => [
|
||||||
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
|
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
|
||||||
@@ -384,5 +401,72 @@ describe('filter-store', () => {
|
|||||||
expect(useFilterStore.getState().activeScriptId).toBeNull();
|
expect(useFilterStore.getState().activeScriptId).toBeNull();
|
||||||
expect(useFilterStore.getState().rules).toEqual([]);
|
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 { generateScript } from '@/lib/sieve/generator';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
|
|
||||||
|
interface SieveAccount {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface FilterStore {
|
interface FilterStore {
|
||||||
rules: FilterRule[];
|
rules: FilterRule[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -17,9 +23,12 @@ interface FilterStore {
|
|||||||
rawScript: string;
|
rawScript: string;
|
||||||
vacationSettings: VacationSieveConfig | null;
|
vacationSettings: VacationSieveConfig | null;
|
||||||
externalRequires: string[];
|
externalRequires: string[];
|
||||||
|
availableAccounts: SieveAccount[];
|
||||||
|
selectedAccountId: string | null;
|
||||||
|
|
||||||
setSupported: (supported: boolean) => void;
|
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>;
|
saveFilters: (client: IJMAPClient) => Promise<void>;
|
||||||
validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
|
validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
|
||||||
addRule: (rule: FilterRule) => void;
|
addRule: (rule: FilterRule) => void;
|
||||||
@@ -44,16 +53,23 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
rawScript: '',
|
rawScript: '',
|
||||||
vacationSettings: null,
|
vacationSettings: null,
|
||||||
externalRequires: [],
|
externalRequires: [],
|
||||||
|
availableAccounts: [],
|
||||||
|
selectedAccountId: null,
|
||||||
|
|
||||||
setSupported: (supported) => set({ isSupported: supported }),
|
setSupported: (supported) => set({ isSupported: supported }),
|
||||||
|
|
||||||
fetchFilters: async (client) => {
|
fetchFilters: async (client, accountId) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
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 });
|
set({ sieveCapabilities: capabilities });
|
||||||
|
|
||||||
const allScripts = await client.getSieveScripts();
|
const allScripts = await client.getSieveScripts(resolvedId);
|
||||||
debug.log('filters', 'Sieve scripts fetched:', allScripts.length);
|
debug.log('filters', 'Sieve scripts fetched:', allScripts.length);
|
||||||
|
|
||||||
// Skip the server-managed 'vacation' script (RFC 9661 §4) - it can only
|
// 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 });
|
set({ activeScriptId: activeScript.id });
|
||||||
|
|
||||||
const content = await client.getSieveScriptContent(activeScript.blobId);
|
const content = await client.getSieveScriptContent(activeScript.blobId, resolvedId);
|
||||||
set({ rawScript: content });
|
set({ rawScript: content });
|
||||||
|
|
||||||
const result = parseScript(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) => {
|
saveFilters: async (client) => {
|
||||||
set({ isSaving: true, error: null });
|
set({ isSaving: true, error: null });
|
||||||
try {
|
try {
|
||||||
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires } = get();
|
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, selectedAccountId } = get();
|
||||||
|
|
||||||
let content: string;
|
let content: string;
|
||||||
if (isOpaque) {
|
if (isOpaque) {
|
||||||
@@ -114,9 +145,9 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeScriptId) {
|
if (activeScriptId) {
|
||||||
await client.updateSieveScript(activeScriptId, content, true);
|
await client.updateSieveScript(activeScriptId, content, true, selectedAccountId || undefined);
|
||||||
} else {
|
} else {
|
||||||
const script = await client.createSieveScript('filters', content, true);
|
const script = await client.createSieveScript('filters', content, true, selectedAccountId || undefined);
|
||||||
set({ activeScriptId: script.id });
|
set({ activeScriptId: script.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +164,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
validateScript: async (client, content) => {
|
validateScript: async (client, content) => {
|
||||||
return client.validateSieveScript(content);
|
return client.validateSieveScript(content, get().selectedAccountId || undefined);
|
||||||
},
|
},
|
||||||
|
|
||||||
addRule: (rule) => {
|
addRule: (rule) => {
|
||||||
@@ -204,5 +235,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
rawScript: '',
|
rawScript: '',
|
||||||
vacationSettings: null,
|
vacationSettings: null,
|
||||||
externalRequires: [],
|
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;
|
error: string | null;
|
||||||
isSupported: boolean;
|
isSupported: boolean;
|
||||||
|
|
||||||
fetchVacationResponse: (client: IJMAPClient) => Promise<void>;
|
fetchVacationResponse: (client: IJMAPClient, accountId?: string) => Promise<void>;
|
||||||
updateVacationResponse: (client: IJMAPClient, updates: {
|
updateVacationResponse: (client: IJMAPClient, updates: {
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
fromDate?: string | null;
|
fromDate?: string | null;
|
||||||
@@ -21,7 +21,7 @@ interface VacationStore {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
textBody?: string;
|
textBody?: string;
|
||||||
htmlBody?: string | null;
|
htmlBody?: string | null;
|
||||||
}) => Promise<void>;
|
}, accountId?: string) => Promise<void>;
|
||||||
setSupported: (supported: boolean) => void;
|
setSupported: (supported: boolean) => void;
|
||||||
clearState: () => void;
|
clearState: () => void;
|
||||||
}
|
}
|
||||||
@@ -38,10 +38,10 @@ export const useVacationStore = create<VacationStore>()((set) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
isSupported: false,
|
isSupported: false,
|
||||||
|
|
||||||
fetchVacationResponse: async (client) => {
|
fetchVacationResponse: async (client, accountId) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const vacation = await client.getVacationResponse();
|
const vacation = await client.getVacationResponse(accountId);
|
||||||
set({
|
set({
|
||||||
isEnabled: vacation.isEnabled,
|
isEnabled: vacation.isEnabled,
|
||||||
fromDate: vacation.fromDate,
|
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 });
|
set({ isSaving: true, error: null });
|
||||||
try {
|
try {
|
||||||
await client.setVacationResponse(updates);
|
await client.setVacationResponse(updates, accountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
...state,
|
...state,
|
||||||
...updates,
|
...updates,
|
||||||
|
|||||||
Reference in New Issue
Block a user