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 { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom"; 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 { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-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 activeAccount = accounts.find((a) => a.id === activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount); const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const removeAccount = useAuthStore((s) => s.removeAccount);
const logoutAll = useAuthStore((s) => s.logoutAll); const logoutAll = useAuthStore((s) => s.logoutAll);
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
@@ -100,6 +101,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
router.push(`/login?mode=add-account` as never); 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 = () => { const handleLogout = () => {
setOpen(false); setOpen(false);
logout(); logout();
@@ -221,7 +229,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
className={cn( className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors", "w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted", isActive ? "bg-accent/50" : "hover:bg-muted",
isDraggable && "pe-7" (!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
)} )}
role="menuitem" role="menuitem"
disabled={isActive} disabled={isActive}
@@ -264,11 +272,22 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
{isDraggable && ( {isDraggable && (
<span <span
aria-hidden 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" /> <GripVertical className="w-4 h-4" />
</span> </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> </div>
); );
})} })}
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Naplánováno", "scheduled": "Naplánováno",
"unified_all_unread": "Vše nepřečtené", "unified_all_unread": "Vše nepřečtené",
"unified_all_starred": "Vše s hvězdičkou", "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": { "protocol_handlers": {
"title": "Výchozí aplikace", "title": "Výchozí aplikace",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Planlagt", "scheduled": "Planlagt",
"unified_all_unread": "Alle ulæste", "unified_all_unread": "Alle ulæste",
"unified_all_starred": "Alle med stjerne", "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": { "protocol_handlers": {
"title": "Standardapps", "title": "Standardapps",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Geplant", "scheduled": "Geplant",
"unified_all_unread": "Alle ungelesenen", "unified_all_unread": "Alle ungelesenen",
"unified_all_starred": "Alle markierten", "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": { "protocol_handlers": {
"title": "Standard-Apps", "title": "Standard-Apps",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Scheduled", "scheduled": "Scheduled",
"unified_all_unread": "All unread", "unified_all_unread": "All unread",
"unified_all_starred": "All starred", "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": { "protocol_handlers": {
"title": "Default apps", "title": "Default apps",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Programados", "scheduled": "Programados",
"unified_all_unread": "Todo no leído", "unified_all_unread": "Todo no leído",
"unified_all_starred": "Todo destacado", "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": { "protocol_handlers": {
"title": "Aplicaciones predeterminadas", "title": "Aplicaciones predeterminadas",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "زمان‌بندی شده", "scheduled": "زمان‌بندی شده",
"unified_all_mail": "همه ایمیل‌ها", "unified_all_mail": "همه ایمیل‌ها",
"unified_all_starred": "همه ستاره‌دارها", "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": { "protocol_handlers": {
"title": "برنامه‌های پیش‌فرض", "title": "برنامه‌های پیش‌فرض",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Planifiés", "scheduled": "Planifiés",
"unified_all_unread": "Tous les non lus", "unified_all_unread": "Tous les non lus",
"unified_all_starred": "Tous les favoris", "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": { "protocol_handlers": {
"title": "Applications par défaut", "title": "Applications par défaut",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Ütemezett", "scheduled": "Ütemezett",
"unified_all_unread": "Összes olvasatlan", "unified_all_unread": "Összes olvasatlan",
"unified_all_starred": "Összes csillagozott", "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": { "protocol_handlers": {
"title": "Alapértelmezett alkalmazások", "title": "Alapértelmezett alkalmazások",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Programmate", "scheduled": "Programmate",
"unified_all_unread": "Tutti i non letti", "unified_all_unread": "Tutti i non letti",
"unified_all_starred": "Tutti gli speciali", "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": { "protocol_handlers": {
"title": "App predefinite", "title": "App predefinite",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "予約済み", "scheduled": "予約済み",
"unified_all_unread": "すべての未読", "unified_all_unread": "すべての未読",
"unified_all_starred": "すべてのスター付き", "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": { "protocol_handlers": {
"title": "既定のアプリ", "title": "既定のアプリ",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "예약됨", "scheduled": "예약됨",
"unified_all_unread": "모든 읽지 않음", "unified_all_unread": "모든 읽지 않음",
"unified_all_starred": "모든 별표", "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": { "protocol_handlers": {
"title": "기본 앱", "title": "기본 앱",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Ieplānots", "scheduled": "Ieplānots",
"unified_all_unread": "Visi nelasītie", "unified_all_unread": "Visi nelasītie",
"unified_all_starred": "Visi ar zvaigzni", "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": { "protocol_handlers": {
"title": "Noklusējuma lietotnes", "title": "Noklusējuma lietotnes",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Gepland", "scheduled": "Gepland",
"unified_all_unread": "Alle ongelezen", "unified_all_unread": "Alle ongelezen",
"unified_all_starred": "Alle met ster", "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": { "protocol_handlers": {
"title": "Standaardapps", "title": "Standaardapps",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Zaplanowane", "scheduled": "Zaplanowane",
"unified_all_unread": "Wszystkie nieprzeczytane", "unified_all_unread": "Wszystkie nieprzeczytane",
"unified_all_starred": "Wszystkie oznaczone gwiazdką", "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": { "protocol_handlers": {
"title": "Aplikacje domyślne", "title": "Aplikacje domyślne",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Agendados", "scheduled": "Agendados",
"unified_all_unread": "Tudo não lido", "unified_all_unread": "Tudo não lido",
"unified_all_starred": "Tudo com estrela", "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": { "protocol_handlers": {
"title": "Aplicativos padrão", "title": "Aplicativos padrão",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Programat", "scheduled": "Programat",
"unified_all_unread": "Toate necitite", "unified_all_unread": "Toate necitite",
"unified_all_starred": "Toate cu stea", "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": { "protocol_handlers": {
"title": "Aplicații implicite", "title": "Aplicații implicite",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Запланировано", "scheduled": "Запланировано",
"unified_all_unread": "Все непрочитанные", "unified_all_unread": "Все непрочитанные",
"unified_all_starred": "Все помеченные", "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": { "protocol_handlers": {
"title": "Приложения по умолчанию", "title": "Приложения по умолчанию",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Zamanlandı", "scheduled": "Zamanlandı",
"unified_all_unread": "Tüm okunmamışlar", "unified_all_unread": "Tüm okunmamışlar",
"unified_all_starred": "Tüm yıldızlı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": { "protocol_handlers": {
"title": "Varsayılan uygulamalar", "title": "Varsayılan uygulamalar",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "Заплановано", "scheduled": "Заплановано",
"unified_all_unread": "Усі непрочитані", "unified_all_unread": "Усі непрочитані",
"unified_all_starred": "Усі позначені", "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": { "protocol_handlers": {
"title": "Програми за замовчуванням", "title": "Програми за замовчуванням",
+3 -1
View File
@@ -138,7 +138,9 @@
"scheduled": "已计划", "scheduled": "已计划",
"unified_all_unread": "全部未读", "unified_all_unread": "全部未读",
"unified_all_starred": "全部加星标", "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": { "protocol_handlers": {
"title": "默认应用", "title": "默认应用",
+26
View File
@@ -44,6 +44,7 @@ interface AuthState {
refreshAccessToken: () => Promise<string | null>; refreshAccessToken: () => Promise<string | null>;
logout: () => void; logout: () => void;
logoutAll: () => void; logoutAll: () => void;
removeAccount: (accountId: string) => void;
switchAccount: (accountId: string) => Promise<void>; switchAccount: (accountId: string) => Promise<void>;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
clearError: () => void; clearError: () => void;
@@ -1243,6 +1244,31 @@ export const useAuthStore = create<AuthState>()(
redirectToLogin(); 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: () => { logoutAll: () => {
// Disconnect all clients // Disconnect all clients
for (const c of clients.values()) { for (const c of clients.values()) {