Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 97 additions & 22 deletions src/components/AccountSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,35 @@ import { useEffect, useRef, useState } from "react";
import { useAccounts } from "../contexts/AccountContext";
import { useI18n } from "../i18n/I18nProvider";
import { AddAccountModal } from "./AddAccountModal";
import { ConfirmDialog } from "./common/ConfirmDialog";
import { ProviderLogo } from "./common/ProviderLogo";

export function AccountSwitcher() {
interface AccountSwitcherProps {
/** Login of the authenticated user, used when the account store has no entry yet. */
authLogin: string | null;
/** Whether the current auth mode supports signing out from the UI. */
canLogout: boolean;
onSignOut: () => void;
}

type PendingAction =
| { kind: "sign-out" }
| { kind: "remove"; id: string; label: string };

const SIGN_OUT_ICON = (
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></svg>
);

/**
* Single account control at the right end of the top bar: shows who is signed
* in, switches between accounts, adds or removes them, and signs out.
*/
export function AccountSwitcher({ authLogin, canLogout, onSignOut }: AccountSwitcherProps) {
const { accounts, active, switchAccount, removeAccount } = useAccounts();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [pending, setPending] = useState<PendingAction | null>(null);
const ref = useRef<HTMLDivElement | null>(null);

useEffect(() => {
Expand All @@ -27,7 +49,8 @@ export function AccountSwitcher() {
};
}, [open]);

if (accounts.length === 0) return null;
const currentLabel = active?.login ?? active?.label ?? authLogin;
if (accounts.length === 0 && !currentLabel) return null;

async function handleSelect(id: string) {
setOpen(false);
Expand All @@ -38,11 +61,27 @@ export function AccountSwitcher() {
}
}

async function handleRemove(event: React.MouseEvent, id: string, label: string) {
function requestRemove(event: React.MouseEvent, id: string, label: string) {
event.stopPropagation();
if (!window.confirm(t("accounts.removeConfirm").replace("{name}", label))) return;
setOpen(false);
setPending({ kind: "remove", id, label });
}

function requestSignOut() {
setOpen(false);
setPending({ kind: "sign-out" });
}

async function confirmPending() {
const action = pending;
setPending(null);
if (!action) return;
if (action.kind === "sign-out") {
onSignOut();
return;
}
try {
await removeAccount(id);
await removeAccount(action.id);
} catch {
// refresh effect surfaces the error
}
Expand All @@ -54,24 +93,24 @@ export function AccountSwitcher() {
<button
type="button"
className={`btn account-switcher-btn ${open ? "active" : ""}`}
aria-haspopup="listbox"
aria-haspopup="menu"
aria-expanded={open}
title={t("accounts.switch")}
title={currentLabel ? `${t("common.signedIn")} · ${currentLabel}` : t("accounts.switch")}
onClick={() => setOpen((value) => !value)}
>
{active ? <ProviderLogo kind={active.providerKind} small className="account-switcher-trigger-logo" /> : null}
<span className="label">{active?.login ?? active?.label ?? t("accounts.select")}</span>
<span className="label">{currentLabel ?? t("accounts.select")}</span>
</button>
{open ? (
<div className="account-switcher-popover" role="listbox" aria-label={t("accounts.switch")}>
<div className="account-switcher-popover" role="menu" aria-label={t("accounts.switch")}>
{accounts.map((account) => {
const isActive = account.id === active?.id;
const labelText = account.login ?? account.label;
return (
<div
key={account.id}
role="option"
aria-selected={isActive}
role="menuitemradio"
aria-checked={isActive}
className={`account-switcher-item ${isActive ? "active" : ""}`}
onClick={() => void handleSelect(account.id)}
>
Expand All @@ -85,7 +124,7 @@ export function AccountSwitcher() {
className="account-switcher-remove"
aria-label={t("accounts.remove").replace("{name}", labelText)}
title={t("accounts.remove").replace("{name}", labelText)}
onClick={(event) => void handleRemove(event, account.id, labelText)}
onClick={(event) => requestRemove(event, account.id, labelText)}
>
×
</button>
Expand All @@ -96,20 +135,56 @@ export function AccountSwitcher() {
</div>
);
})}
<button
type="button"
className="account-switcher-add"
onClick={() => {
setOpen(false);
setAddOpen(true);
}}
>
+ {t("accounts.add")}
</button>
<div className="account-switcher-footer">
<button
type="button"
role="menuitem"
className="account-switcher-add"
onClick={() => {
setOpen(false);
setAddOpen(true);
}}
>
+ {t("accounts.add")}
</button>
{canLogout ? (
<button type="button" role="menuitem" className="account-switcher-signout" onClick={requestSignOut}>
{SIGN_OUT_ICON}
<span>{t("common.signOut")}</span>
</button>
) : (
<span className="account-switcher-external" title={t("common.authenticatedExternally")}>
<svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
{t("common.authenticatedExternally")}
</span>
)}
</div>
</div>
) : null}
</div>
<AddAccountModal open={addOpen} onClose={() => setAddOpen(false)} />
<ConfirmDialog
open={pending?.kind === "sign-out"}
kind={t("accounts.kind")}
title={t("auth.signOutTitle")}
message={<p>{t("auth.signOutMessage", { name: currentLabel ?? "" })}</p>}
confirmLabel={t("common.signOut")}
danger
icon={SIGN_OUT_ICON}
onConfirm={() => void confirmPending()}
onCancel={() => setPending(null)}
/>
<ConfirmDialog
open={pending?.kind === "remove"}
kind={t("accounts.kind")}
title={t("accounts.removeTitle")}
message={<p>{t("accounts.removeConfirm", { name: pending?.kind === "remove" ? pending.label : "" })}</p>}
confirmLabel={t("common.remove")}
danger
icon="×"
onConfirm={() => void confirmPending()}
onCancel={() => setPending(null)}
/>
</>
);
}
13 changes: 1 addition & 12 deletions src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ export function TopBar({
<div className="spacer" />
<div className="topbar-actions">
<span className="meta">{lastUpdated}</span>
<AccountSwitcher />
<button className="btn search-btn" aria-label={t("common.searchShortcut")} title={t("common.searchShortcut")} onClick={onOpenPalette}>
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
<span className="label">{t("common.search")}</span>
Expand Down Expand Up @@ -166,17 +165,7 @@ export function TopBar({
<svg className={loading ? "spin" : ""} width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" /><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" /></svg>
<span className="label">{loading ? t("common.loading") : t("common.refresh")}</span>
</button>
{canLogout ? (
<button className="btn auth-btn" aria-label={t("common.signOut")} title={authLogin ? `${t("common.signedIn")} ${authLogin}` : t("common.signOut")} onClick={onLogout}>
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></svg>
<span className="label">{authLogin || t("common.signOut")}</span>
</button>
) : (
<span className="btn auth-btn" aria-label={t("common.signedIn")} title={authLogin ? `${t("common.signedIn")} ${authLogin}` : t("common.authenticatedExternally")} style={{ cursor: "default", opacity: 0.85 }}>
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
<span className="label">{authLogin || t("common.authenticated")}</span>
</span>
)}
<AccountSwitcher authLogin={authLogin} canLogout={canLogout} onSignOut={onLogout} />
</div>
</div>
);
Expand Down
72 changes: 72 additions & 0 deletions src/components/common/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "../../i18n/I18nProvider";
import { CloseIcon } from "./Icons";

interface ConfirmDialogProps {
open: boolean;
/** Small uppercase label above the title, e.g. the area the action belongs to. */
kind: string;
title: string;
message: ReactNode;
confirmLabel: string;
cancelLabel?: string;
/** Styles the confirm button and icon as a destructive action. */
danger?: boolean;
icon?: ReactNode;
onConfirm: () => void;
onCancel: () => void;
}

/** Themed replacement for `window.confirm`, rendered with the shared modal chrome. */
export function ConfirmDialog({
open,
kind,
title,
message,
confirmLabel,
cancelLabel,
danger = false,
icon,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useI18n();
const cancelRef = useRef<HTMLButtonElement | null>(null);

useEffect(() => {
if (!open) return;
cancelRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onCancel();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onCancel]);

if (!open) return null;

return createPortal(
<div className="modal-root">
<div className="modal-backdrop" onClick={onCancel} />
<div className="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
<header className="modal-head">
<div className="modal-title">
<span className={`modal-icon ${danger ? "danger" : "repository"}`} aria-hidden="true">{icon ?? "?"}</span>
<div style={{ minWidth: 0 }}>
<div className="kind">{kind}</div>
<h3 id="confirm-dialog-title">{title}</h3>
</div>
</div>
<button className="modal-close" type="button" aria-label={t("common.close")} onClick={onCancel}><CloseIcon /></button>
</header>
<div className="modal-body confirm-body">{message}</div>
<footer className="confirm-foot">
<button ref={cancelRef} type="button" className="btn ghost" onClick={onCancel}>{cancelLabel ?? t("common.cancel")}</button>
<button type="button" className={`btn ${danger ? "danger" : "primary"}`} onClick={onConfirm}>{confirmLabel}</button>
</footer>
</div>
</div>,
document.body,
);
}
6 changes: 6 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export const de: Partial<Record<keyof typeof en, string>> = {
"common.openFilters": "Filter öffnen",
"common.closeFilters": "Filter schließen",
"common.signOut": "Abmelden",
"common.cancel": "Abbrechen",
"common.remove": "Entfernen",
"accounts.kind": "Konto",
"accounts.removeTitle": "Konto entfernen?",
"auth.signOutTitle": "Abmelden?",
"auth.signOutMessage": "Du wirst auf diesem Gerät von {name} abgemeldet. Zwischengespeicherte Dashboard-Daten werden gelöscht.",
"common.signedIn": "Angemeldet",
"accounts.switch": "Konto wechseln",
"accounts.select": "Konto auswählen",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export const en = {
"common.openFilters": "Open filters",
"common.closeFilters": "Close filters",
"common.signOut": "Sign out",
"common.cancel": "Cancel",
"common.remove": "Remove",
"accounts.kind": "Account",
"accounts.removeTitle": "Remove account?",
"auth.signOutTitle": "Sign out?",
"auth.signOutMessage": "You will be signed out of {name} on this device. Cached dashboard data will be cleared.",
"common.signedIn": "Signed in",
"accounts.switch": "Switch account",
"accounts.select": "Select account",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export const es: Partial<Record<keyof typeof en, string>> = {
"common.openFilters": "Abrir filtros",
"common.closeFilters": "Cerrar filtros",
"common.signOut": "Cerrar sesión",
"common.cancel": "Cancelar",
"common.remove": "Eliminar",
"accounts.kind": "Cuenta",
"accounts.removeTitle": "¿Eliminar la cuenta?",
"auth.signOutTitle": "¿Cerrar sesión?",
"auth.signOutMessage": "Se cerrará la sesión de {name} en este dispositivo. Los datos del panel en caché se borrarán.",
"common.signedIn": "Sesión iniciada",
"accounts.switch": "Cambiar de cuenta",
"accounts.select": "Seleccionar cuenta",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export const fr: Partial<Record<keyof typeof en, string>> = {
"common.openFilters": "Ouvrir les filtres",
"common.closeFilters": "Fermer les filtres",
"common.signOut": "Se déconnecter",
"common.cancel": "Annuler",
"common.remove": "Supprimer",
"accounts.kind": "Compte",
"accounts.removeTitle": "Supprimer le compte ?",
"auth.signOutTitle": "Se déconnecter ?",
"auth.signOutMessage": "Vous serez déconnecté de {name} sur cet appareil. Les données du tableau de bord en cache seront effacées.",
"common.signedIn": "Connecté",
"accounts.switch": "Changer de compte",
"accounts.select": "Sélectionner un compte",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export const it: Record<keyof typeof en, string> = {
"common.openFilters": "Apri filtri",
"common.closeFilters": "Chiudi filtri",
"common.signOut": "Esci",
"common.cancel": "Annulla",
"common.remove": "Rimuovi",
"accounts.kind": "Account",
"accounts.removeTitle": "Rimuovere l'account?",
"auth.signOutTitle": "Uscire?",
"auth.signOutMessage": "Verrai disconnesso da {name} su questo dispositivo. I dati in cache della dashboard verranno cancellati.",
"common.signedIn": "Accesso effettuato",
"accounts.switch": "Cambia account",
"accounts.select": "Seleziona account",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export const zh: Partial<Record<keyof typeof en, string>> = {
"common.openFilters": "打开筛选",
"common.closeFilters": "关闭筛选",
"common.signOut": "退出登录",
"common.cancel": "取消",
"common.remove": "移除",
"accounts.kind": "账户",
"accounts.removeTitle": "移除账户?",
"auth.signOutTitle": "退出登录?",
"auth.signOutMessage": "将在此设备上退出 {name} 的登录,并清除已缓存的面板数据。",
"common.signedIn": "已登录",
"accounts.switch": "切换账户",
"accounts.select": "选择账户",
Expand Down
23 changes: 23 additions & 0 deletions src/styles/modals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1136,3 +1136,26 @@
color: var(--muted);
}
.command-palette-foot kbd { margin-right: 4px; }

/* Confirm dialog */
.modal.confirm-modal {
width: min(440px, calc(100vw - 32px));
height: auto;
max-height: min(80vh, 520px);
border-radius: 14px;
border: 1px solid var(--border-soft);
}
.modal-icon.danger { background: linear-gradient(135deg, var(--danger), color-mix(in srgb, var(--danger) 65%, black)); }
.confirm-body { padding: 18px; font-size: 13.5px; line-height: 1.55; color: var(--text); }
.confirm-body p { margin: 0; }
.confirm-foot {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 18px; border-top: 1px solid var(--border-soft);
background: var(--panel-2);
}
.confirm-foot .btn { padding: 7px 14px; min-height: 34px; }
.btn.danger {
background: var(--danger); border-color: var(--danger); color: white;
box-shadow: 0 8px 20px color-mix(in srgb, var(--danger) 25%, transparent);
}
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 86%, white); border-color: color-mix(in srgb, var(--danger) 72%, white); }
Loading