From 0fcc932e665f9a29985b007e1eb405d2eca563c8 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 19 Mar 2026 07:44:11 +0100 Subject: [PATCH 1/5] fix: adjust popover alignment to the right --- components/identity/sub-address-helper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/identity/sub-address-helper.tsx b/components/identity/sub-address-helper.tsx index 19a4f9fc..7d8f43b8 100644 --- a/components/identity/sub-address-helper.tsx +++ b/components/identity/sub-address-helper.tsx @@ -135,7 +135,7 @@ export function SubAddressHelper({
Date: Thu, 19 Mar 2026 08:38:59 +0100 Subject: [PATCH 2/5] feat: enhance contact management with import functionality and keyword filtering --- app/[locale]/calendar/page.tsx | 1 + app/[locale]/contacts/page.tsx | 80 ++++- components/contacts/contacts-sidebar.tsx | 293 +++++++++++++++--- .../settings/calendar-management-settings.tsx | 2 +- lib/jmap/client.ts | 7 +- locales/de/common.json | 5 +- locales/en/common.json | 5 +- locales/es/common.json | 5 +- locales/fr/common.json | 5 +- locales/it/common.json | 3 +- locales/ja/common.json | 5 +- locales/nl/common.json | 3 +- locales/pt/common.json | 5 +- stores/contact-store.ts | 81 ++++- 14 files changed, 404 insertions(+), 96 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index df64b196..15870554 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -611,6 +611,7 @@ export default function CalendarPage() { const visibleEvents = useMemo(() => events.filter((e) => { + if (!e.calendarIds) return false; const calIds = Object.keys(e.calendarIds); return calIds.some((id) => selectedCalendarIds.includes(id)); }), diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index dc4d314f..2b9c32df 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -13,6 +13,7 @@ import { ContactForm } from "@/components/contacts/contact-form"; import { ContactGroupForm } from "@/components/contacts/contact-group-form"; import { ContactGroupDetail } from "@/components/contacts/contact-group-detail"; import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar"; +import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { exportContacts } from "@/components/contacts/contact-export"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -73,10 +74,12 @@ export default function ContactsPage() { bulkDeleteContacts, bulkAddToGroup, moveContactToAddressBook, + importContacts, } = useContactStore(); const [view, setView] = useState("list"); const [activeCategory, setActiveCategory] = useState("all"); + const [showImportDialog, setShowImportDialog] = useState(false); const [selectedGroupId, setSelectedGroupId] = useState(null); const hasFetched = useRef(false); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); @@ -84,10 +87,10 @@ export default function ContactsPage() { // Panel resize state - sidebar (categories) const [sidebarWidth, setSidebarWidth] = useState(() => { - try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; } + try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; } }); const [isSidebarResizing, setIsSidebarResizing] = useState(false); - const sidebarDragStartWidth = useRef(180); + const sidebarDragStartWidth = useRef(256); // Panel resize state - contact list const [listWidth, setListWidth] = useState(() => { @@ -125,21 +128,17 @@ export default function ContactsPage() { // Contacts to display based on active category const displayedContacts = useMemo(() => { - if (activeCategory === "all") return individuals.filter(c => !c.isShared); + if (activeCategory === "all") return individuals; if ("addressBookId" in activeCategory) { const bookId = activeCategory.addressBookId; return individuals.filter(c => { if (!c.addressBookIds) return false; - // Check both namespaced (accountId:bookId) and raw bookId - if (c.addressBookIds[bookId]) return true; - // For shared contacts, match namespaced id - if (c.isShared && c.accountId) { - const namespacedId = `${c.accountId}:${Object.keys(c.addressBookIds).find(k => c.addressBookIds[k])}`; - return namespacedId === bookId; - } - return false; + return c.addressBookIds[bookId] === true; }); } + if ("keyword" in activeCategory) { + return individuals.filter(c => c.keywords?.[activeCategory.keyword]); + } // Show members of the selected group return getGroupMembers(activeCategory.groupId); }, [activeCategory, individuals, getGroupMembers]); @@ -151,6 +150,9 @@ export default function ContactsPage() { const book = addressBooks.find(b => b.id === activeCategory.addressBookId); return book?.name || t("tabs.all"); } + if ("keyword" in activeCategory) { + return activeCategory.keyword; + } const group = contacts.find(c => c.id === activeCategory.groupId); return group ? getContactDisplayName(group) : t("tabs.all"); }, [activeCategory, contacts, addressBooks, t]); @@ -160,6 +162,7 @@ export default function ContactsPage() { clearSelection(); if (typeof category === "object" && "groupId" in category) { setSelectedGroupId(category.groupId); + setView("group-detail"); } else { setSelectedGroupId(null); } @@ -179,6 +182,13 @@ export default function ContactsPage() { } }, [client, moveContactToAddressBook, t]); + const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => { + return importContacts( + supportsSync && client ? client : null, + importedContacts + ); + }, [supportsSync, client, importContacts]); + const handleSelectContact = (id: string) => { setSelectedContact(id); clearSelection(); @@ -273,6 +283,35 @@ export default function ContactsPage() { setView("group-edit"); }; + const handleEditGroupFromSidebar = useCallback((groupId: string) => { + setSelectedGroupId(groupId); + setActiveCategory({ groupId }); + setView("group-edit"); + }, []); + + const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => { + const confirmed = await confirmDialog({ + title: t("groups.delete_confirm_title"), + message: t("groups.delete_confirm"), + confirmText: t("form.delete"), + variant: "destructive", + }); + if (!confirmed) return; + + try { + await deleteGroup(supportsSync && client ? client : null, groupId); + toast.success(t("toast.deleted")); + if (selectedGroupId === groupId) { + setSelectedGroupId(null); + setActiveCategory("all"); + setView("list"); + } + } catch (error) { + console.error('Failed to delete group:', error); + toast.error(t("toast.error_delete")); + } + }, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]); + const handleDeleteGroup = async () => { if (!selectedGroup) return; @@ -416,7 +455,6 @@ export default function ContactsPage() { isMobile={isMobile} onSelectMember={(id) => { setSelectedContact(id); - setActiveCategory("all"); setView("detail"); }} /> @@ -548,17 +586,20 @@ export default function ContactsPage() { onSelectCategory={handleSelectCategory} onCreateGroup={handleCreateGroup} onCreateContact={handleCreateNew} + onImport={() => setShowImportDialog(true)} + onEditGroup={handleEditGroupFromSidebar} + onDeleteGroup={handleDeleteGroupFromSidebar} onDropContacts={handleDropContacts} />
{ sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} - onResize={(delta) => setSidebarWidth(Math.max(140, Math.min(300, sidebarDragStartWidth.current + delta)))} + onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} onResizeEnd={() => { setIsSidebarResizing(false); localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); }} - onDoubleClick={() => { setSidebarWidth(180); localStorage.setItem("contacts-sidebar-width", "180"); }} + onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} /> )} @@ -642,6 +683,17 @@ export default function ContactsPage() { + {showImportDialog && ( +
+
+ setShowImportDialog(false)} + /> +
+
+ )} ); } diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 8ce903a8..7973c2b3 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -1,14 +1,16 @@ "use client"; -import { useMemo, useState, useCallback, type DragEvent } from "react"; +import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useTranslations } from "next-intl"; -import { BookUser, Users, Plus, UserPlus, Share2, Book } from "lucide-react"; +import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; -export type ContactCategory = "all" | { groupId: string } | { addressBookId: string }; +export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string }; interface ContactsSidebarProps { groups: ContactCard[]; @@ -18,10 +20,30 @@ interface ContactsSidebarProps { onSelectCategory: (category: ContactCategory) => void; onCreateGroup: () => void; onCreateContact: () => void; + onImport?: () => void; + onEditGroup?: (groupId: string) => void; + onDeleteGroup?: (groupId: string) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; className?: string; } +const COLLAPSED_KEY = "contacts-sidebar-collapsed"; + +function loadCollapsed(): Record { + try { + const v = localStorage.getItem(COLLAPSED_KEY); + return v ? JSON.parse(v) : {}; + } catch { + return {}; + } +} + +function saveCollapsed(state: Record) { + try { + localStorage.setItem(COLLAPSED_KEY, JSON.stringify(state)); + } catch { /* ignore */ } +} + export function ContactsSidebar({ groups, individuals, @@ -30,10 +52,42 @@ export function ContactsSidebar({ onSelectCategory, onCreateGroup, onCreateContact, + onImport, + onEditGroup, + onDeleteGroup, onDropContacts, className, }: ContactsSidebarProps) { const t = useTranslations("contacts"); + const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu(); + + const [collapsed, setCollapsed] = useState>(loadCollapsed); + const [showMenu, setShowMenu] = useState(false); + const menuRef = useRef(null); + const menuBtnRef = useRef(null); + + const toggleSection = useCallback((key: string) => { + setCollapsed(prev => { + const next = { ...prev, [key]: !prev[key] }; + saveCollapsed(next); + return next; + }); + }, []); + + // Close dropdown on outside click + useEffect(() => { + if (!showMenu) return; + const handler = (e: MouseEvent) => { + if ( + menuRef.current && !menuRef.current.contains(e.target as Node) && + menuBtnRef.current && !menuBtnRef.current.contains(e.target as Node) + ) { + setShowMenu(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showMenu]); const sortedGroups = useMemo(() => { return [...groups].sort((a, b) => @@ -73,25 +127,96 @@ export function ContactsSidebar({ if (!contact.addressBookIds) continue; for (const bookId of Object.keys(contact.addressBookIds)) { if (!contact.addressBookIds[bookId]) continue; - // Build the full namespaced key - const key = contact.isShared && contact.accountId ? `${contact.accountId}:${bookId}` : bookId; - counts[key] = (counts[key] || 0) + 1; + counts[bookId] = (counts[bookId] || 0) + 1; } } return counts; }, [individuals]); + // Auto-collect keywords from all contacts + const allKeywords = useMemo(() => { + const counts: Record = {}; + for (const contact of individuals) { + if (!contact.keywords) continue; + for (const [kw, active] of Object.entries(contact.keywords)) { + if (!active) continue; + counts[kw] = (counts[kw] || 0) + 1; + } + } + return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)); + }, [individuals]); + + // Resolve actual group member counts against living contacts + const memberCountByGroup = useMemo(() => { + const counts: Record = {}; + for (const group of groups) { + if (!group.members) { + counts[group.id] = 0; + continue; + } + const memberKeys = Object.keys(group.members).filter(k => group.members![k]); + const normalizedKeys = memberKeys.map(k => k.startsWith('urn:uuid:') ? k.slice(9) : k); + counts[group.id] = individuals.filter(c => { + if (memberKeys.includes(c.id) || normalizedKeys.includes(c.id)) return true; + if (c.uid) { + const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid; + return memberKeys.includes(c.uid) || normalizedKeys.includes(bareUid); + } + return false; + }).length; + } + return counts; + }, [groups, individuals]); + return (
{/* Header */}
{t("title")} - +
+ + {showMenu && ( +
+ + + {onImport && ( + + )} +
+ )} +
- {/* Categories */} + {/* Navigation */}
{/* All contacts */} - {/* Personal address books */} + {/* My Address Books */} {personalBooks.length > 0 && (
-
+
- {personalBooks.map((book) => ( + + {!collapsed.addressBooks && personalBooks.map((book) => ( 0) && ( + {sortedGroups.length > 0 && (
-
+ -
+ - {sortedGroups.map((group) => { + {!collapsed.groups && sortedGroups.map((group) => { const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id; - const memberCount = group.members - ? Object.values(group.members).filter(Boolean).length - : 0; + const memberCount = memberCountByGroup[group.id] || 0; return (
)} - {sortedGroups.length === 0 && ( -
-
- - {t("tabs.groups")} - -
- + {collapsed.categories ? ( + + ) : ( + + )} + + {t("detail.categories")} + + + + {!collapsed.categories && allKeywords.map(([keyword, count]) => { + const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword; + return ( + + ); + })}
)} {/* Shared accounts with address books */} {sharedBookGroups.map((group) => (
-
- - - {group.accountName} +
- {group.books.map((book) => ( + + {!collapsed[`shared-${group.accountId}`] && group.books.map((book) => ( ))}
+ + {/* Group context menu */} + {groupContextMenu.data && ( + + { + closeGroupContextMenu(); + onEditGroup?.(groupContextMenu.data!.id); + }} + /> + + { + closeGroupContextMenu(); + onDeleteGroup?.(groupContextMenu.data!.id); + }} + destructive + /> + + )}
); } @@ -266,7 +463,7 @@ function AddressBookItem({ onDragLeave={handleDragLeave} onDrop={handleDrop} className={cn( - "w-full flex items-center gap-2 px-3 text-sm transition-colors", + "w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors", isActive ? "bg-accent text-accent-foreground font-medium" : "text-foreground/80 hover:bg-muted", diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 991ea495..bd92a0cc 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -296,7 +296,7 @@ export function CalendarManagementSettings() { const buildCalDavUrl = (calendarId: string) => { if (!serverUrl || !username) return null; const base = serverUrl.replace(/\/$/, ''); - return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`; + return `${base}/dav/cal/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`; }; const handleCopyUrl = async (url: string) => { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index be91e659..7c4feb03 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2479,6 +2479,9 @@ export class JMAPClient { ...contact, id: isPrimary ? contact.id : `${accountId}:${contact.id}`, originalId: contact.id, + addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries( + Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v]) + ) : contact.addressBookIds), accountId, accountName: account?.name || (isPrimary ? this.username : accountId), isShared: !isPrimary, @@ -2851,8 +2854,8 @@ export class JMAPClient { id: isPrimary ? event.id : `${accountId}:${event.id}`, originalId: event.id, originalCalendarIds: event.calendarIds, - calendarIds: isPrimary ? event.calendarIds : Object.fromEntries( - Object.entries(event.calendarIds).map(([calId, v]) => [`${accountId}:${calId}`, v]) + calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries( + Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v]) ), accountId, accountName: account?.name || (isPrimary ? this.username : accountId), diff --git a/locales/de/common.json b/locales/de/common.json index 20a01f71..f66f385d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1473,11 +1473,12 @@ "title": "Geteilt" }, "address_books": { - "title": "Verzeichnisse", + "title": "Meine Adressbücher", + "shared_prefix": "Geteilt: {name}", "moved": "Kontakt verschoben nach {name}", "moved_plural": "{count} Kontakte verschoben nach {name}", "move_failed": "Kontakt konnte nicht verschoben werden", - "address_book": "Verzeichnis" + "address_book": "Adressbuch" }, "detail": { "emails": "E-Mail-Adressen", diff --git a/locales/en/common.json b/locales/en/common.json index 9bd0b09d..8027b176 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1473,11 +1473,12 @@ "title": "Shared" }, "address_books": { - "title": "Directories", + "title": "My Address Books", + "shared_prefix": "Shared: {name}", "moved": "Contact moved to {name}", "moved_plural": "{count} contacts moved to {name}", "move_failed": "Failed to move contact", - "address_book": "Directory" + "address_book": "Address Book" }, "detail": { "emails": "Email Addresses", diff --git a/locales/es/common.json b/locales/es/common.json index b61f05bc..8bd161bb 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1473,11 +1473,12 @@ "title": "Compartidos" }, "address_books": { - "title": "Directorios", + "title": "Mis Libretas de Direcciones", + "shared_prefix": "Compartido: {name}", "moved": "Contacto movido a {name}", "moved_plural": "{count} contactos movidos a {name}", "move_failed": "Error al mover el contacto", - "address_book": "Directorio" + "address_book": "Libreta de direcciones" }, "detail": { "emails": "Direcciones de correo", diff --git a/locales/fr/common.json b/locales/fr/common.json index b59f1e53..f4436cd3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1473,11 +1473,12 @@ "title": "Partagés" }, "address_books": { - "title": "Répertoires", + "title": "Mes Carnets d'adresses", + "shared_prefix": "Partagé : {name}", "moved": "Contact déplacé vers {name}", "moved_plural": "{count} contacts déplacés vers {name}", "move_failed": "Échec du déplacement du contact", - "address_book": "Répertoire" + "address_book": "Carnet d'adresses" }, "detail": { "emails": "Adresses e-mail", diff --git a/locales/it/common.json b/locales/it/common.json index c39b9006..af38d806 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1473,7 +1473,8 @@ "title": "Condivisi" }, "address_books": { - "title": "Rubriche", + "title": "Le mie Rubriche", + "shared_prefix": "Condiviso: {name}", "moved": "Contatto spostato in {name}", "moved_plural": "{count} contatti spostati in {name}", "move_failed": "Impossibile spostare il contatto", diff --git a/locales/ja/common.json b/locales/ja/common.json index cc38ba3b..4106fe78 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1473,11 +1473,12 @@ "title": "共有" }, "address_books": { - "title": "ディレクトリ", + "title": "マイアドレス帳", + "shared_prefix": "共有: {name}", "moved": "連絡先を {name} に移動しました", "moved_plural": "{count} 件の連絡先を {name} に移動しました", "move_failed": "連絡先の移動に失敗しました", - "address_book": "ディレクトリ" + "address_book": "アドレス帳" }, "detail": { "emails": "メールアドレス", diff --git a/locales/nl/common.json b/locales/nl/common.json index d80069ff..796330a9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1473,7 +1473,8 @@ "title": "Gedeeld" }, "address_books": { - "title": "Adresboeken", + "title": "Mijn Adresboeken", + "shared_prefix": "Gedeeld: {name}", "moved": "Contact verplaatst naar {name}", "moved_plural": "{count} contacten verplaatst naar {name}", "move_failed": "Verplaatsen van contact mislukt", diff --git a/locales/pt/common.json b/locales/pt/common.json index 0a420620..af31e184 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1473,11 +1473,12 @@ "title": "Compartilhados" }, "address_books": { - "title": "Diretórios", + "title": "Meus Catálogos de Endereços", + "shared_prefix": "Compartilhado: {name}", "moved": "Contato movido para {name}", "moved_plural": "{count} contatos movidos para {name}", "move_failed": "Falha ao mover o contato", - "address_book": "Diretório" + "address_book": "Catálogo de endereços" }, "detail": { "emails": "Endereços de e-mail", diff --git a/stores/contact-store.ts b/stores/contact-store.ts index bb1fe825..32c1cc60 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -87,7 +87,39 @@ interface ContactStore { export const useContactStore = create()( persist( - (set, get) => ({ + (set, get) => { + + // Clean group member references when contacts are removed + function cleanGroupMembers(contacts: ContactCard[], removedIds: Set): ContactCard[] { + // Collect uid/id variants of removed contacts for matching + const removedKeys = new Set(); + for (const c of contacts) { + if (!removedIds.has(c.id)) continue; + removedKeys.add(c.id); + if (c.uid) { + removedKeys.add(c.uid); + const bare = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid; + removedKeys.add(bare); + } + if (c.originalId) removedKeys.add(c.originalId); + } + return contacts.map(c => { + if (c.kind !== 'group' || !c.members) return c; + let changed = false; + const newMembers: Record = {}; + for (const [key, val] of Object.entries(c.members)) { + const bareKey = key.startsWith('urn:uuid:') ? key.slice(9) : key; + if (removedKeys.has(key) || removedKeys.has(bareKey)) { + changed = true; + } else { + newMembers[key] = val; + } + } + return changed ? { ...c, members: newMembers } : c; + }); + } + + return ({ contacts: [], addressBooks: [], selectedContactId: null, @@ -170,10 +202,14 @@ export const useContactStore = create()( const originalId = contact?.originalId || id; const accountId = contact?.isShared ? contact.accountId : undefined; await client.deleteContact(originalId, accountId); - set((state) => ({ - contacts: state.contacts.filter(c => c.id !== id), - selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, - })); + set((state) => { + const removedIds = new Set([id]); + const cleaned = cleanGroupMembers(state.contacts, removedIds); + return { + contacts: cleaned.filter(c => c.id !== id), + selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, + }; + }); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to delete contact'; set({ error: msg }); @@ -191,10 +227,14 @@ export const useContactStore = create()( ), })), - deleteLocalContact: (id) => set((state) => ({ - contacts: state.contacts.filter(c => c.id !== id), - selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, - })), + deleteLocalContact: (id) => set((state) => { + const removedIds = new Set([id]); + const cleaned = cleanGroupMembers(state.contacts, removedIds); + return { + contacts: cleaned.filter(c => c.id !== id), + selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, + }; + }), setSelectedContact: (id) => set({ selectedContactId: id }), setSearchQuery: (query) => set({ searchQuery: query }), @@ -456,11 +496,14 @@ export const useContactStore = create()( } } - set((state) => ({ - contacts: state.contacts.filter(c => !deletedIds.has(c.id)), - selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId, - selectedContactIds: new Set(), - })); + set((state) => { + const cleaned = cleanGroupMembers(state.contacts, deletedIds); + return { + contacts: cleaned.filter(c => !deletedIds.has(c.id)), + selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId, + selectedContactIds: new Set(), + }; + }); }, bulkAddToGroup: async (client, groupId, contactIds) => { @@ -485,9 +528,11 @@ export const useContactStore = create()( // Same account: just update the addressBookIds if ((sourceAccountId || primaryAccountId) === (targetAccountId || primaryAccountId)) { await client.updateContact(originalId, { addressBookIds: { [targetBookOriginalId]: true } }, sourceAccountId); + const isTargetPrimary = !targetAccountId || targetAccountId === primaryAccountId; + const localBookId = isTargetPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`; set((state) => ({ contacts: state.contacts.map(c => - c.id === id ? { ...c, addressBookIds: { [targetBookOriginalId]: true } } : c + c.id === id ? { ...c, addressBookIds: { [localBookId]: true } } : c ), })); } else { @@ -501,6 +546,7 @@ export const useContactStore = create()( // Update local state const isPrimary = !targetAccountId || targetAccountId === primaryAccountId; + const localBookId = isPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`; set((state) => ({ contacts: state.contacts.map(c => { if (c.id !== id) return c; @@ -511,7 +557,7 @@ export const useContactStore = create()( accountId: targetAccountId, accountName: addressBook.accountName || targetAccountId, isShared: !isPrimary, - addressBookIds: { [targetBookOriginalId]: true }, + addressBookIds: { [localBookId]: true }, }; }), })); @@ -544,7 +590,8 @@ export const useContactStore = create()( return imported; }, - }), + }); + }, { name: 'contact-storage', partialize: (state) => ({ From 234129397d550f9698a557fe869d89f0e5cca340 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 19 Mar 2026 08:54:33 +0100 Subject: [PATCH 3/5] feat: improve error logging and enhance settings sync functionality --- app/api/settings/route.ts | 8 ++++++-- lib/settings-sync.ts | 7 +++++-- stores/settings-store.ts | 38 +++++++++++++++++++++++--------------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 7210b410..ccb3eb26 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -47,7 +47,9 @@ export async function GET(request: NextRequest) { } return NextResponse.json({ settings }); } catch (error) { - logger.error('Settings load error', { error: error instanceof Error ? error.message : 'Unknown error' }); + const message = error instanceof Error ? error.message : 'Unknown error'; + const code = (error as NodeJS.ErrnoException).code; + logger.error('Settings load error', { error: message, code }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } @@ -74,7 +76,9 @@ export async function POST(request: NextRequest) { await saveUserSettings(username, serverUrl, settings); return NextResponse.json({ ok: true }); } catch (error) { - logger.error('Settings save error', { error: error instanceof Error ? error.message : 'Unknown error' }); + const message = error instanceof Error ? error.message : 'Unknown error'; + const code = (error as NodeJS.ErrnoException).code; + logger.error('Settings save error', { error: message, code }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } diff --git a/lib/settings-sync.ts b/lib/settings-sync.ts index b4ac52c2..47404e8f 100644 --- a/lib/settings-sync.ts +++ b/lib/settings-sync.ts @@ -1,5 +1,5 @@ import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; -import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'; +import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { logger } from '@/lib/logger'; @@ -45,7 +45,10 @@ export async function saveUserSettings(username: string, serverUrl: string, sett const tag = cipher.getAuthTag(); const data = Buffer.concat([iv, tag, encrypted]); - await writeFile(getSettingsPath(username, serverUrl), data); + const targetPath = getSettingsPath(username, serverUrl); + const tmpPath = targetPath + '.tmp'; + await writeFile(tmpPath, data); + await rename(tmpPath, targetPath); } export async function loadUserSettings(username: string, serverUrl: string): Promise | null> { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 95c3952f..5a0399ec 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -562,26 +562,34 @@ if (typeof window !== 'undefined') { applyAnimations(store.animationsEnabled); // Shared sync function used by all store subscribers + const syncToServer = async (retries = 1): Promise => { + const settings = JSON.parse(useSettingsStore.getState().exportSettings()); + syncLog('Syncing settings to server...'); + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }), + }); + if (res.status === 404) { + syncWarn('Settings sync endpoint returned 404, disabling sync'); + syncEnabled = false; + } else if (res.status >= 500 && retries > 0) { + syncWarn('Settings sync got server error, retrying...'); + await new Promise((r) => setTimeout(r, 2000)); + return syncToServer(retries - 1); + } else if (!res.ok) { + syncError('Settings sync failed with status', res.status); + } else { + syncLog('Settings synced to server successfully'); + } + }; + const triggerSync = () => { if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return; if (syncTimeout) clearTimeout(syncTimeout); syncTimeout = setTimeout(async () => { try { - const settings = JSON.parse(useSettingsStore.getState().exportSettings()); - syncLog('Syncing settings to server...'); - const res = await fetch('/api/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }), - }); - if (res.status === 404) { - syncWarn('Settings sync endpoint returned 404, disabling sync'); - syncEnabled = false; - } else if (!res.ok) { - syncError('Settings sync failed with status', res.status); - } else { - syncLog('Settings synced to server successfully'); - } + await syncToServer(); } catch (error) { syncError('Settings sync error:', error); } From d493bb17dc6534a8400515396339380090a98450 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 19 Mar 2026 10:08:57 +0100 Subject: [PATCH 4/5] feat: implement account switcher component and state management - Add AccountSwitcher component for managing user accounts with UI for switching, adding, and logging out. - Create account state manager to handle snapshots of account-specific states for efficient switching. - Introduce utility functions for account management, including ID generation and avatar color assignment. - Implement Zustand store for account management, supporting addition, removal, and state retrieval of accounts. --- app/[locale]/auth/callback/page.tsx | 1 + app/[locale]/calendar/page.tsx | 2 +- app/[locale]/contacts/page.tsx | 2 +- app/[locale]/files/page.tsx | 2 +- app/[locale]/login/page.tsx | 38 +- app/[locale]/page.tsx | 4 +- app/[locale]/settings/page.tsx | 4 +- app/api/auth/session/route.ts | 39 +- app/api/auth/token/route.ts | 62 ++- components/layout/account-switcher.tsx | 273 +++++++++++ components/layout/navigation-rail.tsx | 9 +- components/layout/sidebar.tsx | 12 +- lib/account-state-manager.ts | 116 +++++ lib/account-utils.ts | 59 +++ lib/auth/session-cookie.ts | 5 + lib/oauth/tokens.ts | 5 + locales/de/common.json | 8 + locales/en/common.json | 8 + locales/es/common.json | 8 + locales/fr/common.json | 8 + locales/it/common.json | 8 + locales/ja/common.json | 8 + locales/nl/common.json | 8 + locales/pt/common.json | 8 + stores/account-store.ts | 184 +++++++ stores/auth-store.ts | 636 ++++++++++++++++++++++--- 26 files changed, 1398 insertions(+), 119 deletions(-) create mode 100644 components/layout/account-switcher.tsx create mode 100644 lib/account-state-manager.ts create mode 100644 lib/account-utils.ts create mode 100644 stores/account-store.ts diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index e9f1b8da..3bc12379 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -53,6 +53,7 @@ function OAuthCallbackInner() { sessionStorage.removeItem("oauth_state"); sessionStorage.removeItem("oauth_code_verifier"); sessionStorage.removeItem("oauth_server_url"); + sessionStorage.removeItem("oauth_add_account_mode"); let redirectTo = `/${params.locale}`; try { const saved = sessionStorage.getItem('redirect_after_login'); diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 15870554..12722d99 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -712,7 +712,7 @@ export default function CalendarPage() { collapsed quota={quota} isPushConnected={isPushConnected} - onLogout={() => { logout(); router.push('/login'); }} + onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onManageApps={handleManageApps} onInlineApp={handleInlineApp} onCloseInlineApp={closeInlineApp} diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 2b9c32df..fbf631dd 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -552,7 +552,7 @@ export default function ContactsPage() { collapsed quota={quota} isPushConnected={isPushConnected} - onLogout={() => { logout(); router.push('/login'); }} + onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onManageApps={handleManageApps} onInlineApp={handleInlineApp} onCloseInlineApp={closeInlineApp} diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx index c35cb02d..e7b45c82 100644 --- a/app/[locale]/files/page.tsx +++ b/app/[locale]/files/page.tsx @@ -357,7 +357,7 @@ export default function FilesPage() { collapsed quota={quota} isPushConnected={isPushConnected} - onLogout={() => { logout(); router.push('/login'); }} + onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onManageApps={handleManageApps} onInlineApp={handleInlineApp} onCloseInlineApp={closeInlineApp} diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 2ccf540e..6b07e17c 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "@/i18n/navigation"; -import { useParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -28,6 +28,8 @@ export default function LoginPage() { const router = useRouter(); const t = useTranslations("login"); const params = useParams(); + const searchParams = useSearchParams(); + const isAddAccountMode = searchParams.get("mode") === "add-account"; const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig(); @@ -102,7 +104,7 @@ export default function LoginPage() { }, [serverUrl]); useEffect(() => { - if (isAuthenticated) { + if (isAuthenticated && !isAddAccountMode) { let redirectTo = '/'; try { const saved = sessionStorage.getItem('redirect_after_login'); @@ -113,7 +115,7 @@ export default function LoginPage() { } catch { /* ignore */ } router.push(redirectTo); } - }, [isAuthenticated, router]); + }, [isAuthenticated, router, isAddAccountMode]); useEffect(() => { clearError(); @@ -303,6 +305,9 @@ export default function LoginPage() { sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_server_url", serverUrl!); + if (isAddAccountMode) { + sessionStorage.setItem("oauth_add_account_mode", "true"); + } const authUrl = new URL(oauthMetadata.authorization_endpoint); authUrl.searchParams.set("response_type", "code"); @@ -329,15 +334,7 @@ export default function LoginPage() { if (success) { saveUsername(formData.username); - let redirectTo = '/'; - try { - const saved = sessionStorage.getItem('redirect_after_login'); - if (saved) { - sessionStorage.removeItem('redirect_after_login'); - redirectTo = saved; - } - } catch { /* ignore */ } - router.push(redirectTo); + router.push('/'); } }; @@ -426,10 +423,10 @@ export default function LoginPage() { />

- {appName} + {isAddAccountMode ? t("add_account_title") : appName}

- {t("title") !== appName ? t("title") : "Sign in to your account"} + {isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}

@@ -737,6 +734,19 @@ export default function LoginPage() { )} )} + + {isAddAccountMode && ( +
+ +
+ )} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 90be1459..6b2cbd20 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -769,7 +769,9 @@ export default function Home() { const handleLogout = () => { logout(); - router.push('/login'); + if (!useAuthStore.getState().isAuthenticated) { + router.push('/login'); + } }; const handleSearch = async (query: string) => { diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 66a3e9a0..0f5b576c 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -286,7 +286,7 @@ export default function SettingsPage() { {/* Logout */}
+ + {open && createPortal( +
+ {/* Account List */} +
+ {accounts.map((account) => { + const isActive = account.id === activeAccountId; + return ( + + ); + })} +
+ + {/* Separator + Add Account */} + {accounts.length < MAX_ACCOUNTS && ( +
+ +
+ )} + + {/* Separator + Actions */} +
+ {activeAccount && !activeAccount.isDefault && accounts.length > 1 && ( + + )} + + {accounts.length > 1 && ( + + )} +
+
, + document.body + )} + + ); +} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 13f7b933..aaa93e12 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react"; +import { AccountSwitcher } from "./account-switcher"; import { icons as lucideIcons, type LucideIcon } from "lucide-react"; import { usePathname, Link } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; @@ -432,13 +433,7 @@ export function NavigationRail({ )} {onLogout && ( - + )}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index b8c7446f..9223f8c6 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -39,6 +39,7 @@ import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; import { useConfig } from "@/hooks/use-config"; import { useThemeStore } from "@/stores/theme-store"; +import { AccountSwitcher } from "./account-switcher"; interface SidebarProps { mailboxes: Mailbox[]; @@ -485,15 +486,8 @@ export function Sidebar({ {isCollapsed ? : } - {!isCollapsed && primaryIdentity && ( -
-

- {primaryIdentity.name} -

-

- {primaryIdentity.email} -

-
+ {!isCollapsed && ( + )} diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts new file mode 100644 index 00000000..6c39606d --- /dev/null +++ b/lib/account-state-manager.ts @@ -0,0 +1,116 @@ +/** + * Manages per-account state snapshots for fast switching. + * When user switches from Account A → B, we snapshot A's store state + * into memory, clear stores, then restore B's cached state. + */ + +import { useEmailStore } from '@/stores/email-store'; +import { useContactStore } from '@/stores/contact-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useFilterStore } from '@/stores/filter-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useVacationStore } from '@/stores/vacation-store'; + +// Minimal snapshot shapes — we only capture what we need +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type StoreSnapshot = Record; + +interface AccountSnapshot { + email: StoreSnapshot; + contact: StoreSnapshot; + calendar: StoreSnapshot; + filter: StoreSnapshot; + identity: StoreSnapshot; + vacation: StoreSnapshot; +} + +const cache = new Map(); + +/** Capture current store states for the given account */ +export function snapshotAccount(accountId: string): void { + const emailState = useEmailStore.getState(); + const contactState = useContactStore.getState(); + const calendarState = useCalendarStore.getState(); + const filterState = useFilterStore.getState(); + const identityState = useIdentityStore.getState(); + const vacationState = useVacationStore.getState(); + + cache.set(accountId, { + email: { + emails: emailState.emails, + mailboxes: emailState.mailboxes, + selectedEmail: emailState.selectedEmail, + selectedMailbox: emailState.selectedMailbox, + searchQuery: emailState.searchQuery, + quota: emailState.quota, + }, + contact: { + contacts: contactState.contacts, + addressBooks: contactState.addressBooks, + supportsSync: contactState.supportsSync, + }, + calendar: { + calendars: calendarState.calendars, + events: calendarState.events, + selectedCalendarIds: calendarState.selectedCalendarIds, + viewMode: calendarState.viewMode, + supportsCalendar: calendarState.supportsCalendar, + }, + filter: { + rules: filterState.rules, + isSupported: filterState.isSupported, + }, + identity: { + identities: identityState.identities, + preferredPrimaryId: identityState.preferredPrimaryId, + }, + vacation: { + isEnabled: vacationState.isEnabled, + isSupported: vacationState.isSupported, + }, + }); +} + +/** Restore cached store states for the given account. Returns false if no cache exists. */ +export function restoreAccount(accountId: string): boolean { + const snapshot = cache.get(accountId); + if (!snapshot) return false; + + useEmailStore.setState(snapshot.email); + useContactStore.setState(snapshot.contact); + useCalendarStore.setState(snapshot.calendar); + useFilterStore.setState(snapshot.filter); + useIdentityStore.setState(snapshot.identity); + useVacationStore.setState(snapshot.vacation); + + return true; +} + +/** Clear all stores (used before restoring a different account) */ +export function clearAllStores(): void { + useEmailStore.setState({ + emails: [], + mailboxes: [], + selectedEmail: null, + selectedMailbox: '', + isLoading: false, + error: null, + searchQuery: '', + quota: null, + }); + useIdentityStore.getState().clearIdentities(); + useContactStore.getState().clearContacts(); + useVacationStore.getState().clearState(); + useCalendarStore.getState().clearState(); + useFilterStore.getState().clearState(); +} + +/** Evict cached state for one account */ +export function evictAccount(accountId: string): void { + cache.delete(accountId); +} + +/** Evict all cached states */ +export function evictAll(): void { + cache.clear(); +} diff --git a/lib/account-utils.ts b/lib/account-utils.ts new file mode 100644 index 00000000..fa646969 --- /dev/null +++ b/lib/account-utils.ts @@ -0,0 +1,59 @@ +/** + * Utilities for multi-account support: + * - Account ID generation + * - Deterministic avatar colors + * - Account-scoped localStorage keys + */ + +/** Generate a unique, deterministic account ID from username and server URL */ +export function generateAccountId(username: string, serverUrl: string): string { + const host = new URL(serverUrl).hostname; + return `${username}@${host}`; +} + +/** Deterministic avatar/accent color from an email string */ +export function generateAvatarColor(email: string): string { + let hash = 0; + for (let i = 0; i < email.length; i++) { + hash = ((hash << 5) - hash + email.charCodeAt(i)) | 0; + } + // 12 distinct, accessible hues + const colors = [ + '#2563eb', // blue + '#7c3aed', // violet + '#db2777', // pink + '#dc2626', // red + '#ea580c', // orange + '#d97706', // amber + '#65a30d', // lime + '#16a34a', // green + '#0d9488', // teal + '#0891b2', // cyan + '#6366f1', // indigo + '#9333ea', // purple + ]; + return colors[Math.abs(hash) % colors.length]; +} + +/** Get initials for an avatar from a display name or email */ +export function getInitials(name: string, email?: string): string { + if (name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + return parts[0][0]?.toUpperCase() ?? '?'; + } + if (email) { + return email[0]?.toUpperCase() ?? '?'; + } + return '?'; +} + +/** Build an account-scoped localStorage key */ +export function getAccountScopedKey(baseKey: string, accountId: string): string { + return `${baseKey}::${accountId}`; +} + +/** Maximum number of accounts allowed */ +export const MAX_ACCOUNTS = 5; diff --git a/lib/auth/session-cookie.ts b/lib/auth/session-cookie.ts index 86ae3cd9..f91ac781 100644 --- a/lib/auth/session-cookie.ts +++ b/lib/auth/session-cookie.ts @@ -1,2 +1,7 @@ export const SESSION_COOKIE = 'jmap_session'; export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60; + +/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */ +export function sessionCookieName(slot: number): string { + return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`; +} diff --git a/lib/oauth/tokens.ts b/lib/oauth/tokens.ts index f91df7c9..5f05e9d3 100644 --- a/lib/oauth/tokens.ts +++ b/lib/oauth/tokens.ts @@ -1,2 +1,7 @@ export const OAUTH_SCOPES = 'openid email profile'; export const REFRESH_TOKEN_COOKIE = 'jmap_rt'; + +/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */ +export function refreshTokenCookieName(slot: number): string { + return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`; +} diff --git a/locales/de/common.json b/locales/de/common.json index f66f385d..2d9785c9 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -34,6 +34,9 @@ "dismiss": "Schließen", "or": "oder", "sign_in_sso": "Mit SSO anmelden", + "add_account_title": "Konto hinzufügen", + "add_account_subtitle": "Mit einem anderen Konto anmelden", + "cancel": "Abbrechen", "website": "Webseite", "imprint": "Impressum", "privacy_policy": "Datenschutz", @@ -58,6 +61,11 @@ "storage_free": "Frei", "storage_total": "Gesamt", "sign_out": "Abmelden", + "sign_out_of": "Von {account} abmelden", + "sign_out_all": "Von allen Konten abmelden", + "add_account": "Konto hinzufügen", + "set_as_default": "Als Standard festlegen", + "switch_account": "Konto wechseln", "contacts": "Kontakte", "calendar": "Kalender", "settings": "Einstellungen", diff --git a/locales/en/common.json b/locales/en/common.json index 8027b176..ac30c871 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -34,6 +34,9 @@ "dismiss": "Dismiss", "or": "or", "sign_in_sso": "Sign in with SSO", + "add_account_title": "Add Account", + "add_account_subtitle": "Sign in with another account", + "cancel": "Cancel", "website": "Website", "imprint": "Imprint", "privacy_policy": "Privacy Policy", @@ -58,6 +61,11 @@ "storage_free": "Free", "storage_total": "Total", "sign_out": "Sign out", + "sign_out_of": "Sign out of {account}", + "sign_out_all": "Sign out of all accounts", + "add_account": "Add account", + "set_as_default": "Set as default", + "switch_account": "Switch account", "contacts": "Contacts", "calendar": "Calendar", "settings": "Settings", diff --git a/locales/es/common.json b/locales/es/common.json index 8bd161bb..84f1581d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -34,6 +34,9 @@ "dismiss": "Cerrar", "or": "o", "sign_in_sso": "Iniciar sesión con SSO", + "add_account_title": "Agregar cuenta", + "add_account_subtitle": "Iniciar sesión con otra cuenta", + "cancel": "Cancelar", "website": "Sitio web", "imprint": "Aviso legal", "privacy_policy": "Política de privacidad", @@ -58,6 +61,11 @@ "storage_free": "Libre", "storage_total": "Total", "sign_out": "Cerrar sesión", + "sign_out_of": "Cerrar sesión de {account}", + "sign_out_all": "Cerrar sesión de todas las cuentas", + "add_account": "Agregar cuenta", + "set_as_default": "Establecer como predeterminada", + "switch_account": "Cambiar cuenta", "contacts": "Contactos", "calendar": "Calendario", "settings": "Configuración", diff --git a/locales/fr/common.json b/locales/fr/common.json index f4436cd3..e508e358 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -34,6 +34,9 @@ "dismiss": "Fermer", "or": "ou", "sign_in_sso": "Se connecter avec SSO", + "add_account_title": "Ajouter un compte", + "add_account_subtitle": "Se connecter avec un autre compte", + "cancel": "Annuler", "website": "Site web", "imprint": "Mentions légales", "privacy_policy": "Politique de confidentialité", @@ -58,6 +61,11 @@ "storage_free": "Libre", "storage_total": "Total", "sign_out": "Se déconnecter", + "sign_out_of": "Se déconnecter de {account}", + "sign_out_all": "Se déconnecter de tous les comptes", + "add_account": "Ajouter un compte", + "set_as_default": "Définir par défaut", + "switch_account": "Changer de compte", "contacts": "Contacts", "calendar": "Calendrier", "settings": "Paramètres", diff --git a/locales/it/common.json b/locales/it/common.json index af38d806..05cb66db 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -34,6 +34,9 @@ "dismiss": "Chiudi", "or": "o", "sign_in_sso": "Accedi con SSO", + "add_account_title": "Aggiungi account", + "add_account_subtitle": "Accedi con un altro account", + "cancel": "Annulla", "website": "Sito web", "imprint": "Note legali", "privacy_policy": "Informativa sulla privacy", @@ -58,6 +61,11 @@ "storage_free": "Libero", "storage_total": "Totale", "sign_out": "Esci", + "sign_out_of": "Disconnetti da {account}", + "sign_out_all": "Disconnetti da tutti gli account", + "add_account": "Aggiungi account", + "set_as_default": "Imposta come predefinito", + "switch_account": "Cambia account", "contacts": "Contatti", "calendar": "Calendario", "settings": "Impostazioni", diff --git a/locales/ja/common.json b/locales/ja/common.json index 4106fe78..d78b436b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -34,6 +34,9 @@ "dismiss": "閉じる", "or": "または", "sign_in_sso": "SSOでサインイン", + "add_account_title": "アカウントを追加", + "add_account_subtitle": "別のアカウントでサインイン", + "cancel": "キャンセル", "website": "ウェブサイト", "imprint": "サイト運営者情報", "privacy_policy": "プライバシーポリシー", @@ -58,6 +61,11 @@ "storage_free": "空き", "storage_total": "合計", "sign_out": "サインアウト", + "sign_out_of": "{account} からサインアウト", + "sign_out_all": "すべてのアカウントからサインアウト", + "add_account": "アカウントを追加", + "set_as_default": "デフォルトに設定", + "switch_account": "アカウントを切り替え", "contacts": "連絡先", "calendar": "カレンダー", "settings": "設定", diff --git a/locales/nl/common.json b/locales/nl/common.json index 796330a9..0fd3ae33 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -34,6 +34,9 @@ "dismiss": "Sluiten", "or": "of", "sign_in_sso": "Inloggen met SSO", + "add_account_title": "Account toevoegen", + "add_account_subtitle": "Inloggen met een ander account", + "cancel": "Annuleren", "website": "Website", "imprint": "Colofon", "privacy_policy": "Privacybeleid", @@ -58,6 +61,11 @@ "storage_free": "Vrij", "storage_total": "Totaal", "sign_out": "Afmelden", + "sign_out_of": "Uitloggen van {account}", + "sign_out_all": "Uitloggen van alle accounts", + "add_account": "Account toevoegen", + "set_as_default": "Als standaard instellen", + "switch_account": "Account wisselen", "contacts": "Contacten", "calendar": "Agenda", "settings": "Instellingen", diff --git a/locales/pt/common.json b/locales/pt/common.json index af31e184..386ad422 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -34,6 +34,9 @@ "dismiss": "Fechar", "or": "ou", "sign_in_sso": "Entrar com SSO", + "add_account_title": "Adicionar conta", + "add_account_subtitle": "Entrar com outra conta", + "cancel": "Cancelar", "website": "Site", "imprint": "Informações legais", "privacy_policy": "Política de privacidade", @@ -58,6 +61,11 @@ "storage_free": "Livre", "storage_total": "Total", "sign_out": "Sair", + "sign_out_of": "Sair de {account}", + "sign_out_all": "Sair de todas as contas", + "add_account": "Adicionar conta", + "set_as_default": "Definir como padrão", + "switch_account": "Trocar conta", "contacts": "Contatos", "calendar": "Calendário", "settings": "Configurações", diff --git a/stores/account-store.ts b/stores/account-store.ts new file mode 100644 index 00000000..c92941d9 --- /dev/null +++ b/stores/account-store.ts @@ -0,0 +1,184 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { generateAccountId, generateAvatarColor, MAX_ACCOUNTS } from '@/lib/account-utils'; + +export interface AccountEntry { + /** Unique key: `${username}@${serverHostname}` */ + id: string; + /** Display label (defaults to email, user-editable) */ + label: string; + /** Full server URL */ + serverUrl: string; + /** Username / email used to authenticate */ + username: string; + /** Authentication mode */ + authMode: 'basic' | 'oauth'; + /** Cookie slot index (0–4) for session/token cookies */ + cookieSlot: number; + /** Whether "Remember Me" was checked (basic auth only) */ + rememberMe: boolean; + /** Cached display info */ + displayName: string; + email: string; + avatarColor: string; + /** Timestamp of last successful login */ + lastLoginAt: number; + /** Whether this account is currently connected */ + isConnected: boolean; + /** Whether this account had a connection error */ + hasError: boolean; + errorMessage?: string; + /** Whether this is the default account (loaded on app start) */ + isDefault: boolean; +} + +interface AccountState { + accounts: AccountEntry[]; + activeAccountId: string | null; + defaultAccountId: string | null; + + addAccount: (entry: Omit) => string; + removeAccount: (accountId: string) => void; + setActiveAccount: (accountId: string) => void; + setDefaultAccount: (accountId: string) => void; + getDefaultAccount: () => AccountEntry | null; + updateAccount: (accountId: string, updates: Partial) => void; + getActiveAccount: () => AccountEntry | null; + getAccountById: (accountId: string) => AccountEntry | undefined; + getNextCookieSlot: () => number; + hasAccount: (username: string, serverUrl: string) => boolean; +} + +export const useAccountStore = create()( + persist( + (set, get) => ({ + accounts: [], + activeAccountId: null, + defaultAccountId: null, + + addAccount: (entry) => { + const state = get(); + if (state.accounts.length >= MAX_ACCOUNTS) { + throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`); + } + + const id = generateAccountId(entry.username, entry.serverUrl); + if (state.accounts.some((a) => a.id === id)) { + return id; // already exists, return existing id + } + + const cookieSlot = state.getNextCookieSlot(); + const avatarColor = generateAvatarColor(entry.email || entry.username); + const isDefault = state.accounts.length === 0; // first account is default + + const account: AccountEntry = { + ...entry, + id, + cookieSlot, + avatarColor, + isDefault, + }; + + set((s) => ({ + accounts: [...s.accounts, account], + // If there is no active account, activate this one + activeAccountId: s.activeAccountId ?? id, + defaultAccountId: isDefault ? id : s.defaultAccountId, + })); + + return id; + }, + + removeAccount: (accountId) => { + set((s) => { + const remaining = s.accounts.filter((a) => a.id !== accountId); + const wasDefault = s.defaultAccountId === accountId; + const wasActive = s.activeAccountId === accountId; + + let newDefault = s.defaultAccountId; + if (wasDefault) { + newDefault = remaining[0]?.id ?? null; + // Mark new default + if (newDefault) { + const idx = remaining.findIndex((a) => a.id === newDefault); + if (idx >= 0) { + remaining[idx] = { ...remaining[idx], isDefault: true }; + } + } + } + + return { + accounts: remaining, + activeAccountId: wasActive ? (remaining[0]?.id ?? null) : s.activeAccountId, + defaultAccountId: newDefault, + }; + }); + }, + + setActiveAccount: (accountId) => { + const account = get().accounts.find((a) => a.id === accountId); + if (!account) return; + set({ activeAccountId: accountId }); + }, + + setDefaultAccount: (accountId) => { + const account = get().accounts.find((a) => a.id === accountId); + if (!account) return; + set((s) => ({ + defaultAccountId: accountId, + accounts: s.accounts.map((a) => ({ + ...a, + isDefault: a.id === accountId, + })), + })); + }, + + getDefaultAccount: () => { + const state = get(); + if (state.defaultAccountId) { + const account = state.accounts.find((a) => a.id === state.defaultAccountId); + if (account) return account; + } + return state.accounts[0] ?? null; + }, + + updateAccount: (accountId, updates) => { + set((s) => ({ + accounts: s.accounts.map((a) => + a.id === accountId ? { ...a, ...updates } : a + ), + })); + }, + + getActiveAccount: () => { + const state = get(); + return state.accounts.find((a) => a.id === state.activeAccountId) ?? null; + }, + + getAccountById: (accountId) => { + return get().accounts.find((a) => a.id === accountId); + }, + + getNextCookieSlot: () => { + const used = new Set(get().accounts.map((a) => a.cookieSlot)); + for (let i = 0; i < MAX_ACCOUNTS; i++) { + if (!used.has(i)) return i; + } + return 0; // fallback, shouldn't happen if max is enforced + }, + + hasAccount: (username, serverUrl) => { + const id = generateAccountId(username, serverUrl); + return get().accounts.some((a) => a.id === id); + }, + }), + { + name: 'account-registry', + partialize: (state) => ({ + accounts: state.accounts, + activeAccountId: state.activeAccountId, + defaultAccountId: state.defaultAccountId, + }), + } + ) +); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index ec37bdfd..d9ba5702 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1,15 +1,17 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { JMAPClient } from '@/lib/jmap/client'; -import { useEmailStore } from './email-store'; import { useIdentityStore } from './identity-store'; import { useContactStore } from './contact-store'; import { useVacationStore } from './vacation-store'; import { useCalendarStore } from './calendar-store'; import { useFilterStore } from './filter-store'; import { useSettingsStore } from './settings-store'; +import { useAccountStore } from './account-store'; import { fetchConfig } from '@/hooks/use-config'; import { debug } from '@/lib/debug'; +import { generateAccountId } from '@/lib/account-utils'; +import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; import type { Identity } from '@/lib/jmap/types'; interface AuthState { @@ -26,14 +28,18 @@ interface AuthState { accessToken: string | null; tokenExpiresAt: number | null; connectionLost: boolean; + activeAccountId: string | null; login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise; loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise; refreshAccessToken: () => Promise; logout: () => void; + logoutAll: () => void; + switchAccount: (accountId: string) => Promise; checkAuth: () => Promise; clearError: () => void; syncIdentities: () => void; + getClientForAccount: (accountId: string) => JMAPClient | undefined; } const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [ @@ -130,22 +136,55 @@ function initializeFeatureStores(client: JMAPClient): void { let refreshTimer: ReturnType | null = null; let refreshPromise: Promise | null = null; -function scheduleRefresh(expiresIn: number, refreshFn: () => Promise): void { - if (refreshTimer) clearTimeout(refreshTimer); - const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000); - refreshTimer = setTimeout(() => { - refreshFn().catch((err) => { - debug.error('Scheduled token refresh failed:', err); - }); - }, refreshAt); +// Multi-account state: per-account JMAP clients and refresh timers +const clients = new Map(); +const refreshTimers = new Map>(); +const refreshPromises = new Map>(); + +function scheduleRefresh(expiresIn: number, refreshFn: () => Promise, accountId?: string): void { + if (accountId) { + const existing = refreshTimers.get(accountId); + if (existing) clearTimeout(existing); + const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000); + refreshTimers.set(accountId, setTimeout(() => { + refreshFn().catch((err) => { + debug.error(`Scheduled token refresh failed for ${accountId}:`, err); + }); + }, refreshAt)); + } else { + if (refreshTimer) clearTimeout(refreshTimer); + const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000); + refreshTimer = setTimeout(() => { + refreshFn().catch((err) => { + debug.error('Scheduled token refresh failed:', err); + }); + }, refreshAt); + } } -function clearRefreshTimer(): void { - if (refreshTimer) { - clearTimeout(refreshTimer); - refreshTimer = null; +function clearRefreshTimer(accountId?: string): void { + if (accountId) { + const timer = refreshTimers.get(accountId); + if (timer) { + clearTimeout(timer); + refreshTimers.delete(accountId); + } + refreshPromises.delete(accountId); + } else { + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + refreshPromise = null; } +} + +function clearAllRefreshTimers(): void { + if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; } refreshPromise = null; + for (const timer of refreshTimers.values()) clearTimeout(timer); + refreshTimers.clear(); + refreshPromises.clear(); } export const useAuthStore = create()( @@ -164,6 +203,7 @@ export const useAuthStore = create()( accessToken: null, tokenExpiresAt: null, connectionLost: false, + activeAccountId: null, login: async (serverUrl, username, password, totp, rememberMe) => { const effectivePassword = totp ? `${password}$${totp}` : password; @@ -179,6 +219,37 @@ export const useAuthStore = create()( const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username); initializeFeatureStores(client); + // Register in account store + const accountStore = useAccountStore.getState(); + const accountId = generateAccountId(username, serverUrl); + const cookieSlot = accountStore.hasAccount(username, serverUrl) + ? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot()) + : accountStore.getNextCookieSlot(); + + // Snapshot current account if switching away + const prevAccountId = get().activeAccountId; + if (prevAccountId && prevAccountId !== accountId) { + snapshotAccount(prevAccountId); + } + + // Store client in multi-account map + clients.set(accountId, client); + + accountStore.addAccount({ + label: primaryIdentity?.name || username, + serverUrl, + username, + authMode: 'basic', + rememberMe: !!rememberMe, + displayName: primaryIdentity?.name || username, + email: primaryIdentity?.email || username, + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + isDefault: accountStore.accounts.length === 0, + }); + accountStore.setActiveAccount(accountId); + set({ isAuthenticated: true, isLoading: false, @@ -192,6 +263,7 @@ export const useAuthStore = create()( tokenExpiresAt: null, connectionLost: false, error: null, + activeAccountId: accountId, }); // Sync settings from server (only if enabled) @@ -204,10 +276,10 @@ export const useAuthStore = create()( if (rememberMe) { try { - const res = await fetch('/api/auth/session', { + const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverUrl, username, password: effectivePassword }), + body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }), }); if (res.ok) { set({ rememberMe: true }); @@ -236,10 +308,17 @@ export const useAuthStore = create()( set({ isLoading: true, error: null }); try { - const tokenRes = await fetch('/api/auth/token', { + // Determine slot for this account (use slot from sessionStorage if re-adding) + const accountStore = useAccountStore.getState(); + const pendingSlot = typeof window !== 'undefined' + ? parseInt(sessionStorage.getItem('oauth_cookie_slot') || '0', 10) + : 0; + const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot(); + + const tokenRes = await fetch(`/api/auth/token?slot=${slot}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri }), + body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri, slot }), }); if (!tokenRes.ok) { @@ -259,6 +338,32 @@ export const useAuthStore = create()( const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username); initializeFeatureStores(client); + // Register in account store + const accountId = generateAccountId(username, serverUrl); + + // Snapshot current account if switching away + const prevAccountId = get().activeAccountId; + if (prevAccountId && prevAccountId !== accountId) { + snapshotAccount(prevAccountId); + } + + clients.set(accountId, client); + + accountStore.addAccount({ + label: primaryIdentity?.name || username, + serverUrl, + username, + authMode: 'oauth', + rememberMe: true, + displayName: primaryIdentity?.name || username, + email: primaryIdentity?.email || username, + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + isDefault: accountStore.accounts.length === 0, + }); + accountStore.setActiveAccount(accountId); + set({ isAuthenticated: true, isLoading: false, @@ -272,9 +377,10 @@ export const useAuthStore = create()( tokenExpiresAt: Date.now() + expires_in * 1000, connectionLost: false, error: null, + activeAccountId: accountId, }); - scheduleRefresh(expires_in, get().refreshAccessToken); + scheduleRefresh(expires_in, get().refreshAccessToken, accountId); // Sync settings from server (only if enabled) fetchConfig().then(config => { @@ -284,6 +390,11 @@ export const useAuthStore = create()( }); }).catch(() => {}); + // Clean up sessionStorage + if (typeof window !== 'undefined') { + sessionStorage.removeItem('oauth_cookie_slot'); + } + return true; } catch (error) { debug.error('OAuth login error:', error); @@ -300,9 +411,17 @@ export const useAuthStore = create()( refreshAccessToken: async () => { if (refreshPromise) return refreshPromise; - refreshPromise = (async () => { + const accountId = get().activeAccountId; + if (accountId && refreshPromises.has(accountId)) { + return refreshPromises.get(accountId)!; + } + + const account = accountId ? useAccountStore.getState().getAccountById(accountId) : null; + const slot = account?.cookieSlot ?? 0; + + const promise = (async () => { try { - const res = await fetch('/api/auth/token', { method: 'PUT' }); + const res = await fetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' }); if (!res.ok) { markSessionExpired(); @@ -319,7 +438,7 @@ export const useAuthStore = create()( tokenExpiresAt: Date.now() + expires_in * 1000, }); - scheduleRefresh(expires_in, get().refreshAccessToken); + scheduleRefresh(expires_in, get().refreshAccessToken, accountId ?? undefined); return access_token; } catch (error) { debug.error('Token refresh failed:', error); @@ -328,21 +447,136 @@ export const useAuthStore = create()( return null; } finally { refreshPromise = null; + if (accountId) refreshPromises.delete(accountId); } })(); - return refreshPromise; + refreshPromise = promise; + if (accountId) refreshPromises.set(accountId, promise); + + return promise; }, logout: () => { const state = get(); const wasOAuth = state.authMode === 'oauth'; + const accountId = state.activeAccountId; + const accountStore = useAccountStore.getState(); + const account = accountId ? accountStore.getAccountById(accountId) : null; + const slot = account?.cookieSlot ?? 0; - clearRefreshTimer(); + clearRefreshTimer(accountId ?? undefined); state.client?.disconnect(); + // Remove client from multi-account map + if (accountId) { + clients.delete(accountId); + evictAccount(accountId); + accountStore.removeAccount(accountId); + } + useSettingsStore.getState().disableSync(); + // Check if there are remaining accounts to switch to + const remainingAccounts = accountStore.accounts; + if (remainingAccounts.length > 0) { + // Switch to the next account + const nextAccount = remainingAccounts[0]; + // Clean current stores, then switch + clearAllStores(); + + // Restore next account + const nextClient = clients.get(nextAccount.id); + if (nextClient) { + const restored = restoreAccount(nextAccount.id); + accountStore.setActiveAccount(nextAccount.id); + + set({ + isAuthenticated: true, + isLoading: false, + serverUrl: nextAccount.serverUrl, + username: nextAccount.username, + client: nextClient, + authMode: nextAccount.authMode, + connectionLost: false, + error: null, + activeAccountId: nextAccount.id, + }); + + if (!restored) { + initializeFeatureStores(nextClient); + nextClient.getIdentities().then((rawIds) => { + const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username); + set({ identities, primaryIdentity }); + }).catch((err) => debug.error('Failed to load identities after switch:', err)); + } else { + const identityState = useIdentityStore.getState(); + set({ + identities: identityState.identities, + primaryIdentity: identityState.identities[0] ?? null, + }); + } + } + } else { + // No accounts remaining — full logout + set({ + isAuthenticated: false, + serverUrl: null, + username: null, + client: null, + identities: [], + primaryIdentity: null, + authMode: 'basic', + rememberMe: false, + accessToken: null, + tokenExpiresAt: null, + connectionLost: false, + error: null, + activeAccountId: null, + }); + + localStorage.removeItem('auth-storage'); + clearAllStores(); + } + + // Clean up cookies for the removed account + fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE' }).catch((err) => { + debug.error('Failed to clear session cookie:', err); + }); + + if (wasOAuth) { + fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE' }) + .then((res) => { + if (!res.ok) throw new Error(`Revocation failed: ${res.status}`); + return res.json(); + }) + .then((data) => { + if (data.end_session_url && remainingAccounts.length === 0) { + const locale = window.location.pathname.split('/')[1] || 'en'; + const redirectUri = `${window.location.origin}/${locale}/login`; + const url = new URL(data.end_session_url); + url.searchParams.set('post_logout_redirect_uri', redirectUri); + window.location.href = url.toString(); + } + }) + .catch((err) => { + debug.error('OAuth logout cleanup failed:', err); + }); + } + }, + + logoutAll: () => { + // Disconnect all clients + for (const client of clients.values()) { + client.disconnect(); + } + clients.clear(); + clearAllRefreshTimers(); + evictAll(); + + useSettingsStore.getState().disableSync(); + useAccountStore.getState().accounts.forEach(() => {}); + set({ isAuthenticated: false, serverUrl: null, @@ -356,55 +590,283 @@ export const useAuthStore = create()( tokenExpiresAt: null, connectionLost: false, error: null, + activeAccountId: null, }); localStorage.removeItem('auth-storage'); + clearAllStores(); - useEmailStore.setState({ - emails: [], - mailboxes: [], - selectedEmail: null, - selectedMailbox: "", - isLoading: false, - error: null, - searchQuery: "", - quota: null, - }); - - useIdentityStore.getState().clearIdentities(); - useContactStore.getState().clearContacts(); - useVacationStore.getState().clearState(); - useCalendarStore.getState().clearState(); - useFilterStore.getState().clearState(); - - fetch('/api/auth/session', { method: 'DELETE' }).catch((err) => { - debug.error('Failed to clear session cookie:', err); - }); - - if (wasOAuth) { - fetch('/api/auth/token', { method: 'DELETE' }) - .then((res) => { - if (!res.ok) throw new Error(`Revocation failed: ${res.status}`); - return res.json(); - }) - .then((data) => { - if (data.end_session_url) { - const locale = window.location.pathname.split('/')[1] || 'en'; - const redirectUri = `${window.location.origin}/${locale}/login`; - const url = new URL(data.end_session_url); - url.searchParams.set('post_logout_redirect_uri', redirectUri); - window.location.href = url.toString(); - } - }) - .catch((err) => { - debug.error('OAuth logout cleanup failed:', err); - }); + // Clear all accounts from registry + const accountStore = useAccountStore.getState(); + const allAccounts = [...accountStore.accounts]; + for (const account of allAccounts) { + accountStore.removeAccount(account.id); } + + // Delete all cookies + fetch('/api/auth/session?all=true', { method: 'DELETE' }).catch(() => {}); + fetch('/api/auth/token?all=true', { method: 'DELETE' }).catch(() => {}); + }, + + switchAccount: async (accountId: string) => { + const state = get(); + if (state.activeAccountId === accountId) return; + + const accountStore = useAccountStore.getState(); + const targetAccount = accountStore.getAccountById(accountId); + if (!targetAccount) return; + + set({ isLoading: true }); + + // Snapshot current account + if (state.activeAccountId) { + snapshotAccount(state.activeAccountId); + } + + // Clear current stores + clearAllStores(); + useSettingsStore.getState().disableSync(); + + // Get or create client for target account + let targetClient = clients.get(accountId); + + if (!targetClient) { + // Client not connected — try to restore + try { + if (targetAccount.authMode === 'oauth') { + const res = await fetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'PUT' }); + if (res.ok) { + const { access_token, expires_in } = await res.json(); + const refreshFn = get().refreshAccessToken; + targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => refreshFn()); + targetClient.onConnectionChange((connected) => { + if (get().activeAccountId === accountId) { + set({ connectionLost: !connected }); + } + accountStore.updateAccount(accountId, { isConnected: connected }); + }); + await targetClient.connect(); + clients.set(accountId, targetClient); + scheduleRefresh(expires_in, get().refreshAccessToken, accountId); + } + } else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) { + const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`); + if (res.ok) { + const { serverUrl, username, password } = await res.json(); + targetClient = new JMAPClient(serverUrl, username, password); + targetClient.onConnectionChange((connected) => { + if (get().activeAccountId === accountId) { + set({ connectionLost: !connected }); + } + accountStore.updateAccount(accountId, { isConnected: connected }); + }); + await targetClient.connect(); + clients.set(accountId, targetClient); + } + } + } catch (err) { + debug.error(`Failed to restore client for ${accountId}:`, err); + accountStore.updateAccount(accountId, { + isConnected: false, + hasError: true, + errorMessage: err instanceof Error ? err.message : 'Connection failed', + }); + set({ isLoading: false }); + return; + } + } + + if (!targetClient) { + set({ isLoading: false }); + return; + } + + // Restore cached state or fetch fresh + const restored = restoreAccount(accountId); + accountStore.setActiveAccount(accountId); + accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined }); + + set({ + isAuthenticated: true, + isLoading: false, + serverUrl: targetAccount.serverUrl, + username: targetAccount.username, + client: targetClient, + authMode: targetAccount.authMode, + connectionLost: false, + error: null, + activeAccountId: accountId, + }); + + if (!restored) { + // Fetch fresh data + try { + const { identities, primaryIdentity } = loadIdentities(await targetClient.getIdentities(), targetAccount.username); + set({ identities, primaryIdentity }); + initializeFeatureStores(targetClient); + } catch (err) { + debug.error(`Failed to load data for ${accountId}:`, err); + } + } else { + const identityState = useIdentityStore.getState(); + set({ + identities: identityState.identities, + primaryIdentity: identityState.identities[0] ?? null, + }); + } + + // Sync settings + fetchConfig().then(config => { + if (!config.settingsSyncEnabled) return; + useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => { + useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl); + }); + }).catch(() => {}); }, checkAuth: async () => { - const state = get(); + const accountStore = useAccountStore.getState(); + const accounts = accountStore.accounts; + // Multi-account restoration: restore all registered accounts + if (accounts.length > 0) { + set({ isLoading: true }); + + // Determine which account to activate first + const defaultAccount = accountStore.getDefaultAccount(); + const activeId = get().activeAccountId; + const targetId = activeId || defaultAccount?.id || accounts[0].id; + + // Try to connect all accounts + for (const account of accounts) { + if (clients.has(account.id)) continue; // Already connected + + try { + if (account.authMode === 'oauth') { + const res = await fetch(`/api/auth/token?slot=${account.cookieSlot}`, { method: 'PUT' }); + if (res.ok) { + const { access_token, expires_in } = await res.json(); + const refreshFn = get().refreshAccessToken; + const client = JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => refreshFn()); + client.onConnectionChange((connected) => { + if (get().activeAccountId === account.id) { + set({ connectionLost: !connected }); + } + accountStore.updateAccount(account.id, { isConnected: connected }); + }); + await client.connect(); + clients.set(account.id, client); + scheduleRefresh(expires_in, get().refreshAccessToken, account.id); + accountStore.updateAccount(account.id, { isConnected: true, hasError: false }); + } else { + throw new Error(`Token refresh failed: ${res.status}`); + } + } else if (account.authMode === 'basic' && account.rememberMe) { + const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`); + if (res.ok) { + const { serverUrl, username, password } = await res.json(); + const client = new JMAPClient(serverUrl, username, password); + client.onConnectionChange((connected) => { + if (get().activeAccountId === account.id) { + set({ connectionLost: !connected }); + } + accountStore.updateAccount(account.id, { isConnected: connected }); + }); + await client.connect(); + clients.set(account.id, client); + accountStore.updateAccount(account.id, { isConnected: true, hasError: false }); + } else { + throw new Error(`Session cookie missing: ${res.status}`); + } + } else { + // Basic auth without rememberMe — can't restore + throw new Error('No saved session'); + } + } catch (err) { + debug.error(`Failed to restore account ${account.id}:`, err); + accountStore.updateAccount(account.id, { + isConnected: false, + hasError: true, + errorMessage: err instanceof Error ? err.message : 'Restore failed', + }); + } + } + + // Activate the target account + const targetClient = clients.get(targetId); + const targetAccount = accountStore.getAccountById(targetId); + if (targetClient && targetAccount) { + accountStore.setActiveAccount(targetId); + const { identities, primaryIdentity } = loadIdentities(await targetClient.getIdentities(), targetAccount.username); + initializeFeatureStores(targetClient); + + set({ + isAuthenticated: true, + isLoading: false, + serverUrl: targetAccount.serverUrl, + username: targetAccount.username, + client: targetClient, + identities, + primaryIdentity, + authMode: targetAccount.authMode, + connectionLost: false, + error: null, + activeAccountId: targetId, + }); + + fetchConfig().then(config => { + if (!config.settingsSyncEnabled) return; + useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => { + useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl); + }); + }).catch(() => {}); + return; + } + + // If target didn't connect, try any connected account + for (const [id, client] of clients.entries()) { + const acc = accountStore.getAccountById(id); + if (acc) { + accountStore.setActiveAccount(id); + const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), acc.username); + initializeFeatureStores(client); + + set({ + isAuthenticated: true, + isLoading: false, + serverUrl: acc.serverUrl, + username: acc.username, + client, + identities, + primaryIdentity, + authMode: acc.authMode, + connectionLost: false, + error: null, + activeAccountId: id, + }); + return; + } + } + + // No accounts could be restored + markSessionExpired(); + set({ + isAuthenticated: false, + isLoading: false, + client: null, + serverUrl: null, + username: null, + authMode: 'basic', + rememberMe: false, + accessToken: null, + tokenExpiresAt: null, + activeAccountId: null, + }); + return; + } + + // Legacy single-account fallback (for accounts not yet in registry) + const state = get(); if (state.isAuthenticated && !state.client) { if (state.authMode === 'oauth' && state.serverUrl) { set({ isLoading: true }); @@ -418,6 +880,25 @@ export const useAuthStore = create()( }); await client.connect(); + const accountId = generateAccountId(state.username || '', state.serverUrl); + clients.set(accountId, client); + + // Migrate to account registry + accountStore.addAccount({ + label: state.username || '', + serverUrl: state.serverUrl, + username: state.username || '', + authMode: 'oauth', + rememberMe: true, + displayName: state.username || '', + email: state.username || '', + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + isDefault: accountStore.accounts.length === 0, + }); + accountStore.setActiveAccount(accountId); + const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || ''); initializeFeatureStores(client); @@ -428,9 +909,9 @@ export const useAuthStore = create()( identities, primaryIdentity, accessToken: token, + activeAccountId: accountId, }); - // Sync settings from server (only if enabled) fetchConfig().then(config => { if (!config.settingsSyncEnabled) return; useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => { @@ -462,6 +943,25 @@ export const useAuthStore = create()( }); await client.connect(); + const accountId = generateAccountId(username, serverUrl); + clients.set(accountId, client); + + // Migrate to account registry + accountStore.addAccount({ + label: username, + serverUrl, + username, + authMode: 'basic', + rememberMe: state.rememberMe, + displayName: username, + email: username, + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + isDefault: accountStore.accounts.length === 0, + }); + accountStore.setActiveAccount(accountId); + const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username); initializeFeatureStores(client); @@ -474,9 +974,9 @@ export const useAuthStore = create()( identities, primaryIdentity, authMode: 'basic', + activeAccountId: accountId, }); - // Sync settings from server (only if enabled) fetchConfig().then(config => { if (!config.settingsSyncEnabled) return; useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => { @@ -502,6 +1002,7 @@ export const useAuthStore = create()( rememberMe: false, accessToken: null, tokenExpiresAt: null, + activeAccountId: null, }); } @@ -516,6 +1017,10 @@ export const useAuthStore = create()( const primaryIdentity = identities[0] ?? null; set({ identities, primaryIdentity }); }, + + getClientForAccount: (accountId: string) => { + return clients.get(accountId); + }, }), { name: 'auth-storage', @@ -527,6 +1032,7 @@ export const useAuthStore = create()( ? state.isAuthenticated : undefined, rememberMe: state.rememberMe, + activeAccountId: state.activeAccountId, }), } ) From 2edf2fab8932a5360fbcb7d6a4a76ebd8f646d9c Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 19 Mar 2026 10:16:47 +0100 Subject: [PATCH 5/5] chore: bump version to 1.4.3 --- CHANGELOG.md | 16 ++++++++++++++++ README.md | 2 +- VERSION | 2 +- app/[locale]/login/page.tsx | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index facf803b..15f82fc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.4.3 (2026-03-19) + +### Features + +- **Auth**: Implement multi-account support with up to 5 simultaneous accounts and instant switching +- **Auth**: Add account switcher component with connection status, default account selection, and per-account logout +- **Auth**: Support multi-account OAuth and basic auth with per-account session persistence +- **Contacts**: Enhance contacts sidebar with collapsible sections, bulk operations, and address book grouping +- **Contacts**: Add contact import functionality and keyword filtering +- **Settings**: Add per-account encrypted settings storage with server-side sync support + +### Fixes + +- **UI**: Adjust popover alignment in sub-address helper component +- **Settings**: Improve error logging in settings sync functionality + ## 1.4.2 (2026-03-19) ### Features diff --git a/README.md b/README.md index a622c10a..c5c8f64b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-1.4.2-green.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.3-green.svg)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail) diff --git a/VERSION b/VERSION index 9df886c4..428b770e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.2 +1.4.3 diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 6b07e17c..c527bbbf 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; -const APP_VERSION = "1.4.2"; +const APP_VERSION = "1.4.3"; const THEME_OPTIONS = [ { value: "light" as const, icon: Sun, label: "Light" }, diff --git a/package-lock.json b/package-lock.json index 86bddba9..fc179b89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.4.2", + "version": "1.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.4.2", + "version": "1.4.3", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.18", diff --git a/package.json b/package.json index 193f1662..b98d05d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.4.2", + "version": "1.4.3", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only",