mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
594 lines
25 KiB
TypeScript
594 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo, useId } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Check, Plus, LogOut, Mails, Star, ChevronDown, AlertCircle, GripVertical, X, Settings, Cloud } from "@/components/icons";
|
|
import { useTranslations } from "next-intl";
|
|
import type { Mailbox } from "@/lib/jmap/types";
|
|
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { useEmailStore } from "@/stores/email-store";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
|
|
import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread";
|
|
import { isDocumentRTL } from "@/i18n/direction";
|
|
import { cn, formatFileSize } from "@/lib/utils";
|
|
import { useRouter } from "@/i18n/navigation";
|
|
import { Avatar } from "@/components/ui/avatar";
|
|
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
|
import { toUnicodeDomain, toUnicodeEmail } from "@/lib/idn";
|
|
|
|
interface AccountSwitcherProps {
|
|
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
|
|
variant?: "rail" | "expanded" | "header";
|
|
className?: string;
|
|
/**
|
|
* Mountain View only: opens the one list that spans every account. It sits
|
|
* at the head of the account popover, above the addresses it merges,
|
|
* because it is the alternative to picking one of them. Omitted when the
|
|
* unified view is off or out of reach, and then no row is drawn.
|
|
*/
|
|
onSelectAllInboxes?: () => void;
|
|
/** Whether that merged list is the one currently on screen. */
|
|
allInboxesSelected?: boolean;
|
|
}
|
|
|
|
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" | "xl" }) {
|
|
return (
|
|
<Avatar
|
|
name={account.displayName || account.label}
|
|
email={account.email || account.username}
|
|
size="sm"
|
|
className={cn(
|
|
"flex-shrink-0",
|
|
size === "md" && "w-9 h-9 text-sm",
|
|
size === "xl" && "!w-20 !h-20 !text-2xl"
|
|
)}
|
|
disableFavicon
|
|
fallbackColor={account.avatarColor}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** The popover's unread badge: a count, capped, or nothing at all when there is none. */
|
|
function UnreadBadge({ count }: { count: number | undefined }) {
|
|
if (!count) return null;
|
|
return (
|
|
<span className="ms-auto shrink-0 text-xs font-medium tabular-nums text-primary">
|
|
{count > 999 ? "999+" : count}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function AccountSwitcher({
|
|
variant = "rail",
|
|
className,
|
|
onSelectAllInboxes,
|
|
allInboxesSelected = false,
|
|
}: AccountSwitcherProps) {
|
|
const t = useTranslations("sidebar");
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const menuId = useId();
|
|
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
|
// The popover is portalled to <body>, so it lands at the very end of the
|
|
// reading order. Without this the menu is unreachable for screen reader and
|
|
// keyboard users even though it is on screen.
|
|
const closeMenu = useCallback(() => setOpen(false), []);
|
|
const { menuRef: popoverRef, onKeyDown: handleMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
|
|
open,
|
|
onClose: closeMenu,
|
|
triggerRef: buttonRef,
|
|
});
|
|
|
|
// The header avatar is Mountain View's account control; the same component
|
|
// in the rail and the sidebar keeps the list it has always had, so the
|
|
// default layout is untouched.
|
|
const mvPopover = useSettingsStore((s) => s.interfaceLayout === "mountain-view") && variant === "header";
|
|
const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
|
|
const quota = useEmailStore((s) => s.quota);
|
|
const tCommon = useTranslations("common");
|
|
const ownMailboxes = useEmailStore((s) => s.mailboxes);
|
|
|
|
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.
|
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
|
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(() => {
|
|
if (!buttonRef.current) return;
|
|
const rect = buttonRef.current.getBoundingClientRect();
|
|
const rtl = isDocumentRTL();
|
|
if (variant === "header") {
|
|
// Anchored at the inline-end edge of the top bar, so the menu drops
|
|
// down and is pinned by its own trailing edge — anchoring by the
|
|
// leading edge (as "expanded" does) runs it off-screen, which is
|
|
// exactly what happened in RTL where that edge is the left one.
|
|
setPopoverStyle({
|
|
position: "fixed",
|
|
top: rect.bottom + 4,
|
|
maxWidth: "calc(100vw - 16px)",
|
|
...(rtl
|
|
? { left: Math.max(8, rect.left) }
|
|
: { right: Math.max(8, window.innerWidth - rect.right) }),
|
|
});
|
|
} else if (variant === "rail") {
|
|
setPopoverStyle(
|
|
rtl
|
|
? {
|
|
position: "fixed",
|
|
right: window.innerWidth - rect.left + 8,
|
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
|
}
|
|
: {
|
|
position: "fixed",
|
|
left: rect.right + 8,
|
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
|
}
|
|
);
|
|
} else {
|
|
setPopoverStyle(
|
|
rtl
|
|
? {
|
|
position: "fixed",
|
|
right: window.innerWidth - rect.right,
|
|
top: rect.bottom + 4,
|
|
}
|
|
: {
|
|
position: "fixed",
|
|
left: rect.left,
|
|
top: rect.bottom + 4,
|
|
}
|
|
);
|
|
}
|
|
}, [variant]);
|
|
|
|
// Position before the first paint - the portalled popover would otherwise
|
|
// render one frame as an unpositioned block at the end of <body>, shifting
|
|
// the page layout for a split second.
|
|
useLayoutEffect(() => {
|
|
if (open) updatePosition();
|
|
}, [open, updatePosition]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
buttonRef.current?.contains(e.target as Node) ||
|
|
popoverRef.current?.contains(e.target as Node)
|
|
) return;
|
|
setOpen(false);
|
|
};
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
document.addEventListener("keydown", handleEscape);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
document.removeEventListener("keydown", handleEscape);
|
|
};
|
|
}, [open, popoverRef]);
|
|
|
|
const handleSwitch = async (accountId: string) => {
|
|
if (accountId === activeAccountId) return;
|
|
setOpen(false);
|
|
await switchAccount(accountId);
|
|
};
|
|
|
|
const handleAddAccount = () => {
|
|
setOpen(false);
|
|
router.push(`/login?mode=add-account` as never);
|
|
};
|
|
|
|
const handleRemove = (e: React.MouseEvent, account: AccountEntry) => {
|
|
e.stopPropagation();
|
|
const label = toUnicodeEmail(account.email || account.username);
|
|
if (!window.confirm(t("remove_account_confirm", { account: label }))) return;
|
|
removeAccount(account.id);
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
setOpen(false);
|
|
logout();
|
|
};
|
|
|
|
const handleLogoutAll = () => {
|
|
setOpen(false);
|
|
logoutAll();
|
|
};
|
|
|
|
const handleSetDefault = (accountId: string) => {
|
|
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<string | null>(null);
|
|
const [dragOverId, setDragOverId] = useState<string | null>(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).
|
|
// IDN domains arrive in their ASCII (xn--) form; show them as written (#1100).
|
|
const displayName = toUnicodeEmail(activeAccount?.displayName || activeAccount?.label || "");
|
|
const displayEmail = toUnicodeEmail(activeAccount?.email || activeAccount?.username || "");
|
|
|
|
// Mountain View leads its popover with the account you are already in - large avatar,
|
|
// name, address - and lists only the others underneath, since choosing one is
|
|
// the point of opening it. Every row carries the unread waiting there, so the
|
|
// choice can be made without visiting each account in turn.
|
|
const listedAccounts = mvPopover
|
|
? displayAccounts.filter((account) => account.id !== activeAccountId)
|
|
: displayAccounts;
|
|
const unreadByAccount: Record<string, Mailbox[] | undefined> = activeAccountId
|
|
? { ...accountMailboxes, [activeAccountId]: ownMailboxes }
|
|
: accountMailboxes;
|
|
const unreadFor = (account: AccountEntry) =>
|
|
mvPopover ? inboxUnread(unreadByAccount[account.id]) : undefined;
|
|
const allInboxesUnread = totalInboxUnread(
|
|
accounts.filter((account) => account.isConnected).map((account) => account.id),
|
|
unreadByAccount
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={buttonRef}
|
|
onClick={() => setOpen(!open)}
|
|
data-testid="account-switcher"
|
|
data-active-account-id={activeAccountId ?? undefined}
|
|
className={cn(
|
|
// Round under Mountain View, like every other button in its top bar.
|
|
"flex items-center gap-2 transition-colors",
|
|
mvPopover ? "rounded-full" : "rounded-md",
|
|
variant !== "expanded"
|
|
? "justify-center w-10 h-10 hover:bg-muted"
|
|
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
|
|
className
|
|
)}
|
|
title={variant !== "expanded" ? (displayName || displayEmail) : undefined}
|
|
aria-label={t("switch_account")}
|
|
aria-expanded={open}
|
|
aria-haspopup="menu"
|
|
aria-controls={open ? menuId : undefined}
|
|
>
|
|
{activeAccount ? (
|
|
<>
|
|
<AccountAvatar account={activeAccount} size={variant === "expanded" ? "md" : "sm"} />
|
|
{variant === "expanded" && (
|
|
<>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-foreground truncate">{displayName}</p>
|
|
<p className="text-xs text-muted-foreground truncate">{displayEmail}</p>
|
|
</div>
|
|
<ChevronDown className={cn("w-3.5 h-3.5 text-muted-foreground flex-shrink-0 transition-transform", open && "rotate-180")} />
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className={cn(
|
|
"rounded-full bg-muted flex items-center justify-center text-muted-foreground",
|
|
variant === "expanded" ? "w-9 h-9 text-sm" : "w-8 h-8 text-xs"
|
|
)}>
|
|
?
|
|
</div>
|
|
)}
|
|
</button>
|
|
|
|
{open && createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
style={popoverStyle}
|
|
className={cn(
|
|
"text-foreground shadow-lg z-50 overflow-hidden",
|
|
// Width and radius come from the layout's own tokens, keyed on the
|
|
// attribute below. Under Mountain View it is Gmail's tinted card
|
|
// holding a white one, with a close button in the corner.
|
|
mvPopover ? "shadow-xl bg-[var(--mv-search-bg)] p-3 max-h-[calc(100dvh-5rem)] overflow-y-auto" : "w-72 rounded-lg border border-border bg-background"
|
|
)}
|
|
data-mv-account-popover={mvPopover ? "" : undefined}
|
|
role="menu"
|
|
id={menuId}
|
|
aria-label={t("switch_account")}
|
|
onKeyDown={handleMenuKeyDown}
|
|
>
|
|
{mvPopover && (
|
|
<div className="flex justify-end pb-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="grid h-9 w-9 place-items-center rounded-full text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
|
|
aria-label={tCommon("close")}
|
|
title={tCommon("close")}
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{mvPopover && activeAccount && (
|
|
<div className="flex items-center gap-4 rounded-t-3xl bg-background px-4 pt-4 pb-3" data-mv-account-current="">
|
|
<AccountAvatar account={activeAccount} size="xl" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-lg leading-6 truncate">{displayName || displayEmail}</p>
|
|
{displayName && <p className="text-sm text-muted-foreground truncate">{displayEmail}</p>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mvPopover && onSelectAllInboxes && (
|
|
<div className="bg-background border-t border-border/60">
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="all-inboxes"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
onSelectAllInboxes();
|
|
}}
|
|
className={cn(
|
|
"w-full flex items-center gap-3 px-4 py-3 text-start text-sm transition-colors",
|
|
allInboxesSelected ? "bg-accent/50 font-medium" : "hover:bg-muted"
|
|
)}
|
|
>
|
|
<Mails className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
|
|
<span className="truncate">{t("unified_inbox")}</span>
|
|
<UnreadBadge count={allInboxesUnread} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Account List. Under Mountain View the active account is already the
|
|
card above, so with a single login there is no list to draw. */}
|
|
<div
|
|
className={cn(
|
|
"py-1 max-h-64 overflow-y-auto",
|
|
mvPopover && "bg-background border-t border-border/60 max-h-none",
|
|
mvPopover && listedAccounts.length === 0 && "hidden"
|
|
)}
|
|
>
|
|
{listedAccounts.map((account) => {
|
|
const isActive = account.id === activeAccountId;
|
|
const isDraggable = !account.isDefault && accounts.length > 2;
|
|
return (
|
|
<div
|
|
key={account.id}
|
|
draggable={isDraggable}
|
|
onDragStart={isDraggable ? (e) => handleDragStart(e, account.id) : undefined}
|
|
onDragOver={isDraggable ? (e) => handleDragOver(e, account.id) : undefined}
|
|
onDrop={isDraggable ? (e) => handleDrop(e, account.id) : undefined}
|
|
onDragEnd={isDraggable ? resetDrag : undefined}
|
|
className={cn(
|
|
"group/acct relative",
|
|
dragId === account.id && "opacity-50",
|
|
dragOverId === account.id && dragId !== account.id && "border-t-2 border-primary"
|
|
)}
|
|
>
|
|
<button
|
|
onClick={() => handleSwitch(account.id)}
|
|
data-testid="account-option"
|
|
data-account-id={account.id}
|
|
data-account-email={account.email || account.username}
|
|
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",
|
|
(!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
|
|
)}
|
|
role="menuitem"
|
|
disabled={isActive}
|
|
>
|
|
<div className="relative flex-shrink-0">
|
|
<AccountAvatar account={account} size="md" />
|
|
{isActive && (
|
|
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
|
|
<Check className="w-2.5 h-2.5 text-primary-foreground" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-sm font-medium truncate">
|
|
{toUnicodeEmail(account.displayName || account.label)}
|
|
</span>
|
|
{account.isDefault && (
|
|
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" />
|
|
)}
|
|
<UnreadBadge count={unreadFor(account)} />
|
|
</div>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{toUnicodeEmail(account.email || account.username)}
|
|
</p>
|
|
{/* Server and connection state: Mountain View keeps the line
|
|
only for an account in trouble. */}
|
|
<div className={cn("flex items-center gap-1 mt-0.5", mvPopover && !account.hasError && "hidden")}>
|
|
{account.hasError ? (
|
|
<AlertCircle className="w-3 h-3 text-destructive" />
|
|
) : (
|
|
<span className={cn(
|
|
"w-1.5 h-1.5 rounded-full",
|
|
account.isConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
|
)} />
|
|
)}
|
|
<span className="text-[10px] text-muted-foreground truncate">
|
|
{(() => { try { return toUnicodeDomain(new URL(account.serverUrl).hostname); } catch { return account.serverUrl; } })()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{isDraggable && (
|
|
<span
|
|
aria-hidden
|
|
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)}
|
|
role="menuitem"
|
|
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>
|
|
|
|
{/* Mountain View: the account actions as rows closing the white card,
|
|
then "Manage your account" and the storage in use as pills under it. */}
|
|
{mvPopover && (
|
|
<>
|
|
<div className="rounded-b-3xl bg-background border-t border-border/60 py-1" data-mv-account-actions="">
|
|
{accounts.length < getMaxAccounts() && (
|
|
<button
|
|
onClick={handleAddAccount}
|
|
data-testid="add-account"
|
|
className="w-full flex items-center gap-4 px-5 py-3 text-sm text-start hover:bg-muted transition-colors"
|
|
role="menuitem"
|
|
>
|
|
<Plus className="w-5 h-5 text-muted-foreground" />
|
|
{t("mv_add_another_account")}
|
|
</button>
|
|
)}
|
|
{activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
|
|
<button
|
|
onClick={() => handleSetDefault(activeAccount.id)}
|
|
className="w-full flex items-center gap-4 px-5 py-3 text-sm text-start hover:bg-muted transition-colors"
|
|
role="menuitem"
|
|
>
|
|
<Star className="w-5 h-5 text-muted-foreground" />
|
|
{t("set_as_default")}
|
|
</button>
|
|
)}
|
|
{accounts.length > 1 && (
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full flex items-center gap-4 px-5 py-3 text-sm text-start hover:bg-muted transition-colors"
|
|
role="menuitem"
|
|
>
|
|
<LogOut className="w-5 h-5 text-muted-foreground" />
|
|
<span className="truncate">{t("sign_out_of", { account: displayEmail })}</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={accounts.length > 1 ? handleLogoutAll : handleLogout}
|
|
className="w-full flex items-center gap-4 px-5 py-3 text-sm text-start hover:bg-muted transition-colors"
|
|
role="menuitem"
|
|
>
|
|
<LogOut className="w-5 h-5 text-muted-foreground" />
|
|
{accounts.length > 1 ? t("sign_out_all") : t("sign_out")}
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setOpen(false); router.push("/settings" as never); }}
|
|
className="mt-2 w-full flex items-center gap-4 rounded-full bg-background px-5 py-3 text-sm text-start hover:bg-muted transition-colors"
|
|
role="menuitem"
|
|
data-mv-manage-account=""
|
|
>
|
|
<Settings className="w-5 h-5 text-muted-foreground" />
|
|
{t("mv_manage_account")}
|
|
</button>
|
|
{quota && quota.total > 0 && (
|
|
<div className="mt-2 flex items-center gap-4 rounded-full bg-background px-5 py-3 text-sm" data-mv-storage="">
|
|
<Cloud className="w-5 h-5 text-muted-foreground" />
|
|
{t("mv_storage_used", {
|
|
percent: Math.min(100, Math.round((quota.used / quota.total) * 100)),
|
|
total: formatFileSize(quota.total),
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Separator + Add Account */}
|
|
{!mvPopover && accounts.length < getMaxAccounts() && (
|
|
<div className="border-t border-border">
|
|
<button
|
|
onClick={handleAddAccount}
|
|
data-testid="add-account"
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
|
role="menuitem"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{t("add_account")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Separator + Actions. Under Mountain View a single login signs out
|
|
from the pill above, so these rows are only for picking among several. */}
|
|
<div className={cn("border-t border-border", mvPopover && "hidden")}>
|
|
{activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
|
|
<button
|
|
onClick={() => handleSetDefault(activeAccount.id)}
|
|
className={cn("w-full flex items-center gap-2 py-2 text-sm text-foreground hover:bg-muted transition-colors", mvPopover ? "px-4" : "px-3")}
|
|
role="menuitem"
|
|
>
|
|
<Star className="w-4 h-4" />
|
|
{t("set_as_default")}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleLogout}
|
|
className={cn("w-full flex items-center gap-2 py-2 text-sm text-foreground hover:bg-muted transition-colors", mvPopover ? "px-4" : "px-3")}
|
|
role="menuitem"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
{t("sign_out_of", { account: displayEmail })}
|
|
</button>
|
|
{!mvPopover && accounts.length > 1 && (
|
|
<button
|
|
onClick={handleLogoutAll}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-muted transition-colors"
|
|
role="menuitem"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
{t("sign_out_all")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
}
|