From 91afc1b25176accffe2f75b8a904e45bf13e98e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Jan 2026 09:44:44 +0000 Subject: [PATCH] feat: improve multi-account UX and read-only account handling Account Management Improvements: - Auto-switch to another account when logging out (instead of leaving no active account) - If other accounts exist, automatically activates the first one before removing current Account Type Badges: - Added visual badges to distinguish account types in user menu - 'Read-only' badge with eye icon for read-only accounts - 'Extension' badge with puzzle icon for extension accounts - Badges appear in both user menu and account manager Auth Prompt Protection: - Read-only accounts no longer receive relay auth prompts - Auto-reject all auth challenges when active account is read-only - Prevents confusing prompts for accounts that cannot sign Type Safety: - Proper TypeScript casting for account.constructor.type access - Used 'as unknown as { type: string }' pattern for type narrowing --- src/components/AccountManager.tsx | 32 +++++++++++++++++-- src/components/GlobalAuthPrompt.tsx | 28 ++++++++++++++++- src/components/LogoutHandler.tsx | 9 ++++++ src/components/nostr/user-menu.tsx | 48 ++++++++++++++++++++++++----- 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/src/components/AccountManager.tsx b/src/components/AccountManager.tsx index 4e678f9..5ad2bcb 100644 --- a/src/components/AccountManager.tsx +++ b/src/components/AccountManager.tsx @@ -1,17 +1,42 @@ import { useObservableMemo } from "applesauce-react/hooks"; -import { Check, User, UserX, UserPlus } from "lucide-react"; +import { Check, User, UserX, UserPlus, Eye, Puzzle } from "lucide-react"; import { toast } from "sonner"; import accountManager from "@/services/accounts"; import { useProfile } from "@/hooks/useProfile"; import { getDisplayName } from "@/lib/nostr-utils"; import { useAppShell } from "@/components/layouts/AppShellContext"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import Nip05 from "@/components/nostr/nip05"; import type { IAccount } from "applesauce-accounts"; import type { ISigner } from "applesauce-signers"; +function getAccountTypeBadge(account: IAccount) { + const accountType = (account.constructor as unknown as { type: string }).type; + + if (accountType === "grimoire-readonly" || accountType === "readonly") { + return ( + + + Read-only + + ); + } + + if (accountType === "extension") { + return ( + + + Extension + + ); + } + + return null; +} + function AccountCard({ account, isActive, @@ -56,7 +81,10 @@ function AccountCard({
-
{displayName}
+
+
{displayName}
+ {getAccountTypeBadge(account)} +
{profile && (
diff --git a/src/components/GlobalAuthPrompt.tsx b/src/components/GlobalAuthPrompt.tsx index 4ff2dd4..8c4d8ee 100644 --- a/src/components/GlobalAuthPrompt.tsx +++ b/src/components/GlobalAuthPrompt.tsx @@ -5,6 +5,8 @@ import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; import { useRelayState } from "@/hooks/useRelayState"; import { RelayLink } from "./nostr/RelayLink"; +import { useObservableMemo } from "applesauce-react/hooks"; +import accountManager from "@/services/accounts"; interface AuthToastProps { relayUrl: string; @@ -116,11 +118,20 @@ export function GlobalAuthPrompt() { relays, } = useRelayState(); + const activeAccount = useObservableMemo(() => accountManager.active$, []); const activeToasts = useRef>(new Map()); const [authenticatingRelays, setAuthenticatingRelays] = useState>( new Set(), ); + // Check if active account is read-only + const isReadOnly = + activeAccount && + ((activeAccount.constructor as unknown as { type: string }).type === + "grimoire-readonly" || + (activeAccount.constructor as unknown as { type: string }).type === + "readonly"); + // Watch for authentication success and show toast useEffect(() => { authenticatingRelays.forEach((relayUrl) => { @@ -139,6 +150,15 @@ export function GlobalAuthPrompt() { }, [relays, authenticatingRelays]); useEffect(() => { + // Don't show auth prompts if active account is read-only + if (isReadOnly) { + // Auto-reject all pending challenges for read-only accounts + pendingChallenges.forEach((challenge) => { + rejectAuth(challenge.relayUrl, true); + }); + return; + } + // Show toasts for new challenges pendingChallenges.forEach((challenge) => { const key = challenge.relayUrl; @@ -224,7 +244,13 @@ export function GlobalAuthPrompt() { activeToasts.current.delete(relayUrl); } }); - }, [pendingChallenges, authenticateRelay, rejectAuth, setAuthPreference]); + }, [ + pendingChallenges, + authenticateRelay, + rejectAuth, + setAuthPreference, + isReadOnly, + ]); return null; // No UI needed - toasts handle everything } diff --git a/src/components/LogoutHandler.tsx b/src/components/LogoutHandler.tsx index 35f9e23..f8ed76e 100644 --- a/src/components/LogoutHandler.tsx +++ b/src/components/LogoutHandler.tsx @@ -61,6 +61,15 @@ export default function LogoutHandler({ action, all }: LogoutHandlerProps) { return; } + // If there are other accounts, switch to the first one before removing current + const otherAccounts = allAccounts.filter( + (acc) => acc.id !== activeAccount.id, + ); + if (otherAccounts.length > 0) { + accountManager.setActive(otherAccounts[0].id); + } + + // Remove the account accountManager.removeAccount(activeAccount); toast.success("Logged out", { diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 09c85eb..5e6122b 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -1,4 +1,4 @@ -import { User, Check, UserPlus } from "lucide-react"; +import { User, Check, UserPlus, Eye, Puzzle } from "lucide-react"; import accounts from "@/services/accounts"; import { ExtensionSigner } from "applesauce-signers"; import { ExtensionAccount } from "applesauce-accounts/accounts"; @@ -8,6 +8,7 @@ import { getDisplayName } from "@/lib/nostr-utils"; import { useGrimoire } from "@/core/state"; import { useAppShell } from "@/components/layouts/AppShellContext"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -25,6 +26,30 @@ import { useState } from "react"; import type { IAccount } from "applesauce-accounts"; import type { ISigner } from "applesauce-signers"; +function getAccountTypeBadge(account: IAccount) { + const accountType = (account.constructor as unknown as { type: string }).type; + + if (accountType === "grimoire-readonly" || accountType === "readonly") { + return ( + + + Read-only + + ); + } + + if (accountType === "extension") { + return ( + + + Extension + + ); + } + + return null; +} + function UserAvatar({ pubkey }: { pubkey: string }) { const profile = useProfile(pubkey); return ( @@ -40,14 +65,21 @@ function UserAvatar({ pubkey }: { pubkey: string }) { ); } -function UserLabel({ pubkey }: { pubkey: string }) { - const profile = useProfile(pubkey); +function UserLabel({ + account, +}: { + account: IAccount; +}) { + const profile = useProfile(account.pubkey); return ( -
- {getDisplayName(pubkey, profile)} +
+
+ {getDisplayName(account.pubkey, profile)} + {getAccountTypeBadge(account)} +
{profile ? ( - + ) : null}
@@ -128,7 +160,7 @@ export default function UserMenu() { >
- +
@@ -149,7 +181,7 @@ export default function UserMenu() { >
- +
))}