From c262dafe560756fb0d1b818e31524014f15ac668 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Sun, 28 Jun 2026 19:47:38 +0300 Subject: [PATCH 1/2] feat(accounts): pin default account on top + drag-to-reorder switcher The account switcher now always renders the default (starred) account first and lets you drag the remaining accounts into any order. Default stays pinned; only non-default rows are draggable (shown via a grip handle on hover). Wraps each row in a draggable container and persists the new order through the existing reorderAccounts store action. Ordering logic extracted to pure helpers (sortDefaultFirst, reorderNonDefaultIds) in account-utils with unit tests. --- components/layout/account-switcher.tsx | 62 +++++++++++++++++++++++--- lib/__tests__/account-ordering.test.ts | 45 +++++++++++++++++++ lib/account-utils.ts | 38 ++++++++++++++++ 3 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 lib/__tests__/account-ordering.test.ts diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 24df5725..4ddecd61 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -1,12 +1,12 @@ "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; -import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react"; +import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical } from "lucide-react"; import { useTranslations } from "next-intl"; import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; -import { getMaxAccounts } from "@/lib/account-utils"; +import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils"; import { cn } from "@/lib/utils"; import { useRouter } from "@/i18n/navigation"; import { Avatar } from "@/components/ui/avatar"; @@ -40,6 +40,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher const accounts = useAccountStore((s) => s.accounts); const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); + const reorderAccounts = useAccountStore((s) => s.reorderAccounts); // Read activeAccountId from authStore so the selector matches the actually-loaded // session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate // persisted copy that can drift out of sync across hydration / partial persist writes. @@ -113,6 +114,32 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher setDefaultAccount(accountId); }; + // Display order: default account pinned to the top, the rest reorderable. + const displayAccounts = useMemo(() => sortDefaultFirst(accounts), [accounts]); + + // Drag-to-rearrange (non-default accounts only; the default stays pinned). + const [dragId, setDragId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + const resetDrag = () => { setDragId(null); setDragOverId(null); }; + + const handleDragStart = (e: React.DragEvent, id: string) => { + setDragId(id); + e.dataTransfer.effectAllowed = "move"; + }; + const handleDragOver = (e: React.DragEvent, overId: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (overId !== dragOverId) setDragOverId(overId); + }; + const handleDrop = (e: React.DragEvent, overId: string) => { + e.preventDefault(); + if (dragId) { + const next = reorderNonDefaultIds(accounts, dragId, overId); + if (next) reorderAccounts(next); + } + resetDrag(); + }; + // Show the account's own identity, not the preferred sending identity - // primaryIdentity can be an alias (e.g. info@korazo.net) that differs from // the actually logged-in account (info@linusrath.de). @@ -167,15 +194,29 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher > {/* Account List */}
- {accounts.map((account) => { + {displayAccounts.map((account) => { const isActive = account.id === activeAccountId; + const isDraggable = !account.isDefault && accounts.length > 2; return ( -
+ {isDraggable && ( + + + + )} + ); })} diff --git a/lib/__tests__/account-ordering.test.ts b/lib/__tests__/account-ordering.test.ts new file mode 100644 index 00000000..4c102584 --- /dev/null +++ b/lib/__tests__/account-ordering.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { sortDefaultFirst, reorderNonDefaultIds, type OrderableAccount } from '../account-utils'; + +const acct = (id: string, isDefault = false): OrderableAccount => ({ id, isDefault }); + +describe('sortDefaultFirst', () => { + it('pins the default account to the front, preserving the rest order', () => { + const accounts = [acct('a'), acct('b', true), acct('c')]; + expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']); + }); + + it('is a no-op shape when the default is already first', () => { + const accounts = [acct('b', true), acct('a'), acct('c')]; + expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']); + }); + + it('does not mutate the input array', () => { + const accounts = [acct('a'), acct('b', true)]; + const snapshot = accounts.map((a) => a.id); + sortDefaultFirst(accounts); + expect(accounts.map((a) => a.id)).toEqual(snapshot); + }); +}); + +describe('reorderNonDefaultIds', () => { + // default 'd' stays index 0; non-defaults are a, b, c + const accounts = [acct('d', true), acct('a'), acct('b'), acct('c')]; + + it('moves a non-default onto a later position, keeping default pinned', () => { + expect(reorderNonDefaultIds(accounts, 'a', 'c')).toEqual(['d', 'b', 'c', 'a']); + }); + + it('moves a non-default earlier', () => { + expect(reorderNonDefaultIds(accounts, 'c', 'a')).toEqual(['d', 'c', 'a', 'b']); + }); + + it('returns null for a no-op (same id)', () => { + expect(reorderNonDefaultIds(accounts, 'a', 'a')).toBeNull(); + }); + + it('returns null when the default is dragged or targeted', () => { + expect(reorderNonDefaultIds(accounts, 'd', 'a')).toBeNull(); + expect(reorderNonDefaultIds(accounts, 'a', 'd')).toBeNull(); + }); +}); diff --git a/lib/account-utils.ts b/lib/account-utils.ts index ea63020f..41204e37 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -102,3 +102,41 @@ export function isHttp2Available(): boolean { export function getMaxAccounts(): number { return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1; } + +/** Minimal shape needed to order accounts (structural — avoids importing AccountEntry). */ +export interface OrderableAccount { + id: string; + isDefault: boolean; +} + +/** + * Display order for the account switcher: the default account first, then the + * remaining accounts in their stored order. Pure — does not mutate the input. + */ +export function sortDefaultFirst(accounts: T[]): T[] { + const defaults = accounts.filter((a) => a.isDefault); + const rest = accounts.filter((a) => !a.isDefault); + return [...defaults, ...rest]; +} + +/** + * Compute the new full account-id order after dragging `dragId` onto `overId`. + * Default account(s) stay pinned to the front; only non-default accounts are + * reordered (`dragId` is inserted at `overId`'s position among them). + * Returns null when the move is a no-op or invalid (e.g. a default is involved). + */ +export function reorderNonDefaultIds( + accounts: OrderableAccount[], + dragId: string, + overId: string, +): string[] | null { + if (dragId === overId) return null; + const defaults = accounts.filter((a) => a.isDefault).map((a) => a.id); + const nonDefault = accounts.filter((a) => !a.isDefault).map((a) => a.id); + const from = nonDefault.indexOf(dragId); + const to = nonDefault.indexOf(overId); + if (from < 0 || to < 0) return null; + const [moved] = nonDefault.splice(from, 1); + nonDefault.splice(to, 0, moved); + return [...defaults, ...nonDefault]; +} From ae1c0153635493b9c1595b4b57dccbcccc6036a0 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Fri, 3 Jul 2026 20:20:51 +0300 Subject: [PATCH 2/2] feat(accounts): remove a specific account from the switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switcher only offered 'sign out of active' and 'sign out of all' — no way to drop a single non-active account (e.g. one stuck in an error state you can't switch into to sign out). Add a hover × on non-active, non-default rows and a removeAccount(id) auth action that tears down the client, drops it from the registry, and clears its per-slot session/token cookies. Stacks on the switcher redesign in #517. --- components/layout/account-switcher.tsx | 25 ++++++++++++++++++++++--- locales/cs/common.json | 4 +++- locales/da/common.json | 4 +++- locales/de/common.json | 4 +++- locales/en/common.json | 4 +++- locales/es/common.json | 4 +++- locales/fa/common.json | 4 +++- locales/fr/common.json | 4 +++- locales/hu/common.json | 4 +++- locales/it/common.json | 4 +++- locales/ja/common.json | 4 +++- locales/ko/common.json | 4 +++- locales/lv/common.json | 4 +++- locales/nl/common.json | 4 +++- locales/pl/common.json | 4 +++- locales/pt/common.json | 4 +++- locales/ro/common.json | 4 +++- locales/ru/common.json | 4 +++- locales/tr/common.json | 4 +++- locales/uk/common.json | 4 +++- locales/zh/common.json | 4 +++- stores/auth-store.ts | 26 ++++++++++++++++++++++++++ 22 files changed, 108 insertions(+), 23 deletions(-) diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 4ddecd61..ef3e8fbf 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; -import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical } from "lucide-react"; +import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -48,6 +48,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher const activeAccount = accounts.find((a) => a.id === activeAccountId); const switchAccount = useAuthStore((s) => s.switchAccount); const logout = useAuthStore((s) => s.logout); + const removeAccount = useAuthStore((s) => s.removeAccount); const logoutAll = useAuthStore((s) => s.logoutAll); const updatePosition = useCallback(() => { @@ -100,6 +101,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher router.push(`/login?mode=add-account` as never); }; + const handleRemove = (e: React.MouseEvent, account: AccountEntry) => { + e.stopPropagation(); + const label = account.email || account.username; + if (!window.confirm(t("remove_account_confirm", { account: label }))) return; + removeAccount(account.id); + }; + const handleLogout = () => { setOpen(false); logout(); @@ -216,7 +224,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher className={cn( "w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors", isActive ? "bg-accent/50" : "hover:bg-muted", - isDraggable && "pe-7" + (!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7") )} role="menuitem" disabled={isActive} @@ -259,11 +267,22 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher {isDraggable && ( )} + {!isActive && !account.isDefault && ( + + )} ); })} diff --git a/locales/cs/common.json b/locales/cs/common.json index e6203fd8..26989a5d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -137,7 +137,9 @@ "scheduled": "Naplánováno", "unified_all_unread": "Vše nepřečtené", "unified_all_starred": "Vše s hvězdičkou", - "unified_all_mail": "Veškerá pošta" + "unified_all_mail": "Veškerá pošta", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Výchozí aplikace", diff --git a/locales/da/common.json b/locales/da/common.json index 4cfeaa5e..d5fb7992 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -137,7 +137,9 @@ "scheduled": "Planlagt", "unified_all_unread": "Alle ulæste", "unified_all_starred": "Alle med stjerne", - "unified_all_mail": "Al post" + "unified_all_mail": "Al post", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Standardapps", diff --git a/locales/de/common.json b/locales/de/common.json index 81f1e0ee..3ebf03ba 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -137,7 +137,9 @@ "scheduled": "Geplant", "unified_all_unread": "Alle ungelesenen", "unified_all_starred": "Alle markierten", - "unified_all_mail": "Alle Mails" + "unified_all_mail": "Alle Mails", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Standard-Apps", diff --git a/locales/en/common.json b/locales/en/common.json index 20855cb1..7e586346 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -137,7 +137,9 @@ "scheduled": "Scheduled", "unified_all_unread": "All unread", "unified_all_starred": "All starred", - "unified_all_mail": "All mail" + "unified_all_mail": "All mail", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Default apps", diff --git a/locales/es/common.json b/locales/es/common.json index d0e719bd..78c298bf 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -137,7 +137,9 @@ "scheduled": "Programados", "unified_all_unread": "Todo no leído", "unified_all_starred": "Todo destacado", - "unified_all_mail": "Todo el correo" + "unified_all_mail": "Todo el correo", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Aplicaciones predeterminadas", diff --git a/locales/fa/common.json b/locales/fa/common.json index 391485d8..3eb82d94 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -137,7 +137,9 @@ "scheduled": "زمان‌بندی شده", "unified_all_mail": "همه ایمیل‌ها", "unified_all_starred": "همه ستاره‌دارها", - "unified_all_unread": "همه خوانده‌نشده‌ها" + "unified_all_unread": "همه خوانده‌نشده‌ها", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "برنامه‌های پیش‌فرض", diff --git a/locales/fr/common.json b/locales/fr/common.json index 3c4fac6a..d3c5d593 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -137,7 +137,9 @@ "scheduled": "Planifiés", "unified_all_unread": "Tous les non lus", "unified_all_starred": "Tous les favoris", - "unified_all_mail": "Tout le courrier" + "unified_all_mail": "Tout le courrier", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Applications par défaut", diff --git a/locales/hu/common.json b/locales/hu/common.json index cd158799..06df357f 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -137,7 +137,9 @@ "scheduled": "Ütemezett", "unified_all_unread": "Összes olvasatlan", "unified_all_starred": "Összes csillagozott", - "unified_all_mail": "Összes levél" + "unified_all_mail": "Összes levél", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Alapértelmezett alkalmazások", diff --git a/locales/it/common.json b/locales/it/common.json index bb864c9f..e6cf1d87 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -137,7 +137,9 @@ "scheduled": "Programmate", "unified_all_unread": "Tutti i non letti", "unified_all_starred": "Tutti gli speciali", - "unified_all_mail": "Tutta la posta" + "unified_all_mail": "Tutta la posta", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "App predefinite", diff --git a/locales/ja/common.json b/locales/ja/common.json index 5d36ea70..4709fa3e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -137,7 +137,9 @@ "scheduled": "予約済み", "unified_all_unread": "すべての未読", "unified_all_starred": "すべてのスター付き", - "unified_all_mail": "すべてのメール" + "unified_all_mail": "すべてのメール", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "既定のアプリ", diff --git a/locales/ko/common.json b/locales/ko/common.json index 60bead10..b8b948d8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -137,7 +137,9 @@ "scheduled": "예약됨", "unified_all_unread": "모든 읽지 않음", "unified_all_starred": "모든 별표", - "unified_all_mail": "모든 메일" + "unified_all_mail": "모든 메일", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "기본 앱", diff --git a/locales/lv/common.json b/locales/lv/common.json index a77ec36e..9f81c762 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -137,7 +137,9 @@ "scheduled": "Ieplānots", "unified_all_unread": "Visi nelasītie", "unified_all_starred": "Visi ar zvaigzni", - "unified_all_mail": "Visas vēstules" + "unified_all_mail": "Visas vēstules", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Noklusējuma lietotnes", diff --git a/locales/nl/common.json b/locales/nl/common.json index 576289af..5db19a2e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -137,7 +137,9 @@ "scheduled": "Gepland", "unified_all_unread": "Alle ongelezen", "unified_all_starred": "Alle met ster", - "unified_all_mail": "Alle e-mail" + "unified_all_mail": "Alle e-mail", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Standaardapps", diff --git a/locales/pl/common.json b/locales/pl/common.json index d2f9f1a2..966fdcf6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -137,7 +137,9 @@ "scheduled": "Zaplanowane", "unified_all_unread": "Wszystkie nieprzeczytane", "unified_all_starred": "Wszystkie oznaczone gwiazdką", - "unified_all_mail": "Cała poczta" + "unified_all_mail": "Cała poczta", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Aplikacje domyślne", diff --git a/locales/pt/common.json b/locales/pt/common.json index 9fb7f599..3b4539a8 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -137,7 +137,9 @@ "scheduled": "Agendados", "unified_all_unread": "Tudo não lido", "unified_all_starred": "Tudo com estrela", - "unified_all_mail": "Todo o correio" + "unified_all_mail": "Todo o correio", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Aplicativos padrão", diff --git a/locales/ro/common.json b/locales/ro/common.json index 06916535..134254a9 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -137,7 +137,9 @@ "scheduled": "Programat", "unified_all_unread": "Toate necitite", "unified_all_starred": "Toate cu stea", - "unified_all_mail": "Toată poșta" + "unified_all_mail": "Toată poșta", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Aplicații implicite", diff --git a/locales/ru/common.json b/locales/ru/common.json index d5b47ddc..040d79d3 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -137,7 +137,9 @@ "scheduled": "Запланировано", "unified_all_unread": "Все непрочитанные", "unified_all_starred": "Все помеченные", - "unified_all_mail": "Вся почта" + "unified_all_mail": "Вся почта", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Приложения по умолчанию", diff --git a/locales/tr/common.json b/locales/tr/common.json index f5baf9b0..2f85b9a0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -137,7 +137,9 @@ "scheduled": "Zamanlandı", "unified_all_unread": "Tüm okunmamışlar", "unified_all_starred": "Tüm yıldızlılar", - "unified_all_mail": "Tüm postalar" + "unified_all_mail": "Tüm postalar", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Varsayılan uygulamalar", diff --git a/locales/uk/common.json b/locales/uk/common.json index db78274c..81b17f41 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -137,7 +137,9 @@ "scheduled": "Заплановано", "unified_all_unread": "Усі непрочитані", "unified_all_starred": "Усі позначені", - "unified_all_mail": "Уся пошта" + "unified_all_mail": "Уся пошта", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "Програми за замовчуванням", diff --git a/locales/zh/common.json b/locales/zh/common.json index f4f94de3..bac2e397 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -137,7 +137,9 @@ "scheduled": "已计划", "unified_all_unread": "全部未读", "unified_all_starred": "全部加星标", - "unified_all_mail": "全部邮件" + "unified_all_mail": "全部邮件", + "remove_account": "Remove account", + "remove_account_confirm": "Remove {account} from this device? You can add it back later." }, "protocol_handlers": { "title": "默认应用", diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 4aa67f60..69b8ec07 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -44,6 +44,7 @@ interface AuthState { refreshAccessToken: () => Promise; logout: () => void; logoutAll: () => void; + removeAccount: (accountId: string) => void; switchAccount: (accountId: string) => Promise; checkAuth: () => Promise; clearError: () => void; @@ -1057,6 +1058,31 @@ export const useAuthStore = create()( redirectToLogin(); }, + // Remove a specific (typically non-active) account: tear down its client, + // drop it from the registry, and clear its per-slot cookies. If asked to + // remove the active account, defer to logout() which handles switching + // away or redirecting. + removeAccount: (accountId: string) => { + if (accountId === get().activeAccountId) { get().logout(); return; } + const accountStore = useAccountStore.getState(); + const account = accountStore.getAccountById(accountId); + if (!account) return; + const slot = account.cookieSlot ?? 0; + const wasOAuth = account.authMode === 'oauth'; + + clearRefreshTimer(accountId); + const client = clients.get(accountId); + if (client) { try { client.disconnect(); } catch { /* noop */ } } + clients.delete(accountId); + evictAccount(accountId); + accountStore.removeAccount(accountId); + + apiFetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + if (wasOAuth) { + apiFetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + } + }, + logoutAll: () => { // Disconnect all clients for (const c of clients.values()) {