feat(accounts): remove a specific account from the switcher

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.
This commit is contained in:
Shuki Vaknin
2026-07-21 20:59:52 +02:00
committed by Linus Rath
parent 0f3459c2e5
commit 6dfcb07b9a
22 changed files with 108 additions and 23 deletions
+22 -3
View File
@@ -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();
@@ -221,7 +229,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-start 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}
@@ -264,11 +272,22 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
{isDraggable && (
<span
aria-hidden
className="pointer-events-none absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
className="pointer-events-none absolute end-7 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
>
<GripVertical className="w-4 h-4" />
</span>
)}
{!isActive && !account.isDefault && (
<button
type="button"
onClick={(e) => handleRemove(e, account)}
aria-label={t("remove_account")}
title={t("remove_account")}
className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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": "برنامه‌های پیش‌فرض",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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": "既定のアプリ",
+3 -1
View File
@@ -138,7 +138,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": "기본 앱",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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": "Приложения по умолчанию",
+3 -1
View File
@@ -138,7 +138,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",
+3 -1
View File
@@ -138,7 +138,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": "Програми за замовчуванням",
+3 -1
View File
@@ -138,7 +138,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": "默认应用",
+26
View File
@@ -44,6 +44,7 @@ interface AuthState {
refreshAccessToken: () => Promise<string | null>;
logout: () => void;
logoutAll: () => void;
removeAccount: (accountId: string) => void;
switchAccount: (accountId: string) => Promise<void>;
checkAuth: () => Promise<void>;
clearError: () => void;
@@ -1243,6 +1244,31 @@ export const useAuthStore = create<AuthState>()(
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()) {