Fix multi-account restore and switch after restart

- restoreSession pre-loads all nsec accounts from keychain into signer
  cache at startup, not just the active one
- switchAccount updates state to target account on failure instead of
  leaving UI stuck on previous account
- AccountSwitcher shows re-login prompt when account has no signer
- store_nsec now logs warnings instead of silently swallowing errors
This commit is contained in:
Jure
2026-03-12 16:14:23 +01:00
parent f33d78ebcf
commit 56cf0bae4d
2 changed files with 61 additions and 17 deletions

View File

@@ -26,7 +26,7 @@ function Avatar({ account, size = "w-6 h-6", textSize = "text-[10px]" }: { accou
}
export function AccountSwitcher() {
const { accounts, pubkey, switchAccount, removeAccount, logout } = useUserStore();
const { accounts, pubkey, loggedIn, switchAccount, removeAccount, logout } = useUserStore();
const { openProfile } = useUIStore();
const [open, setOpen] = useState(false);
const [showAddLogin, setShowAddLogin] = useState(false);
@@ -137,13 +137,21 @@ export function AccountSwitcher() {
{/* Active account row */}
<div className="px-3 py-2">
{!loggedIn && (
<button
onClick={() => setShowAddLogin(true)}
className="w-full mb-1.5 px-2 py-1 text-[10px] border border-accent/40 text-accent hover:bg-accent/10 transition-colors"
>
re-login to sign
</button>
)}
<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} size="w-8 h-8" textSize="text-[12px]" />
<span className="text-text text-[12px] font-medium truncate flex-1">{displayName(current)}</span>
<span className={`text-[12px] font-medium truncate flex-1 ${loggedIn ? "text-text" : "text-text-muted"}`}>{displayName(current)}</span>
</div>
<button
onClick={() => setOpen((v) => !v)}

View File

@@ -108,8 +108,10 @@ export const useUserStore = create<UserState>((set, get) => ({
localStorage.setItem("wrystr_pubkey", pubkey);
localStorage.setItem("wrystr_login_type", "nsec");
// Store nsec in OS keychain (best-effort — gracefully ignored if unavailable)
invoke<void>("store_nsec", { pubkey, nsec: nsecInput }).catch(() => {});
// Store nsec in OS keychain
invoke<void>("store_nsec", { pubkey, nsec: nsecInput }).catch((err) => {
console.warn("Failed to store nsec in OS keychain:", err);
});
// Load per-account NWC wallet
useLightningStore.getState().loadNwcForAccount(pubkey);
@@ -173,6 +175,29 @@ export const useUserStore = create<UserState>((set, get) => ({
},
restoreSession: async () => {
// Pre-populate signer cache for ALL nsec accounts so switchAccount
// always hits the fast path (no keychain round-trip on every switch).
const accounts = get().accounts;
for (const acct of accounts) {
if (acct.loginType !== "nsec" || _signerCache.has(acct.pubkey)) continue;
try {
const nsec = await invoke<string | null>("load_nsec", { pubkey: acct.pubkey });
if (nsec) {
let privkey: string;
if (nsec.startsWith("nsec1")) {
const decoded = nip19.decode(nsec);
privkey = decoded.data as string;
} else {
privkey = nsec;
}
_signerCache.set(acct.pubkey, new NDKPrivateKeySigner(privkey));
}
} catch (err) {
console.warn(`Failed to load nsec for ${acct.npub} from keychain:`, err);
}
}
// Now restore the active account
const savedPubkey = localStorage.getItem("wrystr_pubkey");
const savedLoginType = localStorage.getItem("wrystr_login_type");
if (!savedPubkey) return;
@@ -183,15 +208,20 @@ export const useUserStore = create<UserState>((set, get) => ({
}
if (savedLoginType === "nsec") {
try {
const nsec = await invoke<string | null>("load_nsec", { pubkey: savedPubkey });
if (nsec) {
await get().loginWithNsec(nsec);
}
// No keychain entry yet → stay logged out, user re-enters nsec once.
} catch {
// Keychain unavailable (e.g. no secret service on this Linux session) — stay logged out.
const cachedSigner = _signerCache.get(savedPubkey);
if (cachedSigner) {
getNDK().signer = cachedSigner;
const npub = nip19.npubEncode(savedPubkey);
set({ pubkey: savedPubkey, npub, loggedIn: true, loginError: null });
localStorage.setItem("wrystr_pubkey", savedPubkey);
localStorage.setItem("wrystr_login_type", "nsec");
useLightningStore.getState().loadNwcForAccount(savedPubkey);
get().fetchOwnProfile();
get().fetchFollows();
useMuteStore.getState().fetchMuteList(savedPubkey);
useNotificationsStore.getState().fetchNotifications(savedPubkey);
}
// No keychain entry → stay logged out, user re-enters nsec once.
}
},
@@ -218,7 +248,7 @@ export const useUserStore = create<UserState>((set, get) => ({
return;
}
// Slow path: cache miss (first session after restart) — try OS keychain
// Slow path: cache miss — try OS keychain
let succeeded = false;
try {
const nsec = await invoke<string | null>("load_nsec", { pubkey });
@@ -226,17 +256,23 @@ export const useUserStore = create<UserState>((set, get) => ({
await get().loginWithNsec(nsec);
succeeded = !!getNDK().signer;
}
} catch {
// Keychain unavailable
} catch (err) {
console.warn("Keychain load failed during account switch:", err);
}
if (!succeeded) {
const account = get().accounts.find((a) => a.pubkey === pubkey);
if (account?.loginType === "pubkey") {
// Deliberately read-only (npub) account — correct behavior
await get().loginWithPubkey(pubkey);
} else {
// nsec account whose keychain entry was lost — update state to reflect
// the target account (logged out) so the UI shows the correct account
// with a login prompt, rather than staying stuck on the previous account.
const npub = account?.npub ?? nip19.npubEncode(pubkey);
set({ pubkey, npub, loggedIn: false, loginError: null, profile: null, follows: [] });
localStorage.setItem("wrystr_pubkey", pubkey);
localStorage.setItem("wrystr_login_type", "nsec");
}
// else: nsec account whose keychain entry was lost.
// Stay logged out; user sees AccountSwitcher "login" button to re-enter nsec.
}
// Always land on feed to avoid stale UI from previous account's view
useUIStore.getState().setView("feed");