Files
vega/src/components/sidebar/AccountSwitcher.tsx
T
Jure a8627b7305 Add multi-account / profile switcher (roadmap #2)
- SavedAccount list persisted in localStorage (wrystr_accounts)
- loginWithNsec / loginWithPubkey now upsert into the accounts list
- fetchOwnProfile caches name + picture into the account entry
- switchAccount: loads nsec from OS keychain, falls back to read-only
- removeAccount: deletes keychain entry + removes from list; logs out
  if it was the active account
- logout: clears active session only — keychain entries kept for instant
  switch-back
- AccountSwitcher component in sidebar footer: shows current account,
  expand (▼/▲) to list all saved accounts, click to switch instantly,
  × to remove, "+ add account" opens LoginModal, sign-out / remove
  account actions inline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:28:39 +01:00

164 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useUserStore, SavedAccount } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
import { LoginModal } from "../shared/LoginModal";
import { shortenPubkey } from "../../lib/utils";
function Avatar({ account, size = 6 }: { account: SavedAccount; size?: number }) {
const initial = (account.name || account.npub || "?").charAt(0).toUpperCase();
const cls = `w-${size} h-${size} rounded-sm object-cover shrink-0`;
if (account.picture) {
return (
<img
src={account.picture}
alt=""
className={cls}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
);
}
return (
<div className={`w-${size} h-${size} rounded-sm bg-accent/20 flex items-center justify-center text-accent text-[10px] shrink-0`}>
{initial}
</div>
);
}
export function AccountSwitcher() {
const { accounts, pubkey, switchAccount, removeAccount, logout } = useUserStore();
const { openProfile } = useUIStore();
const [open, setOpen] = useState(false);
const [showAddLogin, setShowAddLogin] = useState(false);
const current = accounts.find((a) => a.pubkey === pubkey) ?? null;
const others = accounts.filter((a) => a.pubkey !== pubkey);
const displayName = (a: SavedAccount) =>
a.name || shortenPubkey(a.npub);
const handleSwitch = async (targetPubkey: string) => {
setOpen(false);
await switchAccount(targetPubkey);
};
const handleRemove = (e: React.MouseEvent, targetPubkey: string) => {
e.stopPropagation();
removeAccount(targetPubkey);
};
const handleAddAccount = () => {
setOpen(false);
setShowAddLogin(true);
};
// Not logged in
if (!pubkey || !current) {
return (
<>
<div className="border-t border-border px-3 py-2">
{accounts.length > 0 && (
<div className="mb-1.5">
{accounts.map((a) => (
<button
key={a.pubkey}
onClick={() => handleSwitch(a.pubkey)}
className="w-full flex items-center gap-2 px-1 py-1 text-left hover:bg-bg-hover transition-colors"
>
<Avatar account={a} />
<span className="text-text-muted text-[11px] truncate flex-1">{displayName(a)}</span>
</button>
))}
<div className="border-t border-border my-1" />
</div>
)}
<button
onClick={() => setShowAddLogin(true)}
className="w-full px-2 py-1.5 text-[11px] border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
>
login
</button>
</div>
{showAddLogin && <LoginModal onClose={() => setShowAddLogin(false)} />}
</>
);
}
return (
<>
<div className="border-t border-border shrink-0">
{/* Expanded account list */}
{open && (
<div className="border-b border-border">
{others.map((a) => (
<div
key={a.pubkey}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-bg-hover cursor-pointer group transition-colors"
onClick={() => handleSwitch(a.pubkey)}
>
<Avatar account={a} />
<span className="text-text-muted text-[11px] truncate flex-1">{displayName(a)}</span>
<button
onClick={(e) => handleRemove(e, a.pubkey)}
className="text-text-dim hover:text-danger text-[11px] opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove account"
>
×
</button>
</div>
))}
<button
onClick={handleAddAccount}
className="w-full flex items-center gap-2 px-3 py-1.5 text-text-dim hover:text-accent hover:bg-bg-hover text-[11px] transition-colors"
>
<span className="w-6 text-center text-[12px]">+</span>
<span>add account</span>
</button>
</div>
)}
{/* Current account row */}
<div className="px-3 py-2">
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => openProfile(pubkey)}
>
<Avatar account={current} />
<span className="text-text text-[11px] truncate flex-1">{displayName(current)}</span>
</div>
<button
onClick={() => setOpen((v) => !v)}
className="text-text-dim hover:text-text text-[10px] transition-colors px-0.5"
title="Switch account"
>
{open ? "▲" : "▼"}
</button>
</div>
{open && (
<div className="flex items-center justify-between mt-1.5">
<button
onClick={() => { setOpen(false); logout(); }}
className="text-text-dim hover:text-danger text-[10px] transition-colors"
>
sign out
</button>
<button
onClick={() => { setOpen(false); removeAccount(pubkey); }}
className="text-text-dim hover:text-danger text-[10px] transition-colors"
>
remove account
</button>
</div>
)}
</div>
</div>
{showAddLogin && <LoginModal onClose={() => setShowAddLogin(false)} />}
</>
);
}