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
This commit is contained in:
Claude
2026-01-05 09:44:44 +00:00
parent cfd9bf3c04
commit 91afc1b251
4 changed files with 106 additions and 11 deletions

View File

@@ -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<ISigner, unknown, unknown>) {
const accountType = (account.constructor as unknown as { type: string }).type;
if (accountType === "grimoire-readonly" || accountType === "readonly") {
return (
<Badge variant="secondary" className="text-xs">
<Eye className="size-3 mr-1" />
Read-only
</Badge>
);
}
if (accountType === "extension") {
return (
<Badge variant="secondary" className="text-xs">
<Puzzle className="size-3 mr-1" />
Extension
</Badge>
);
}
return null;
}
function AccountCard({
account,
isActive,
@@ -56,7 +81,10 @@ function AccountCard({
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{displayName}</div>
<div className="flex items-center gap-2 mb-1">
<div className="font-medium truncate">{displayName}</div>
{getAccountTypeBadge(account)}
</div>
{profile && (
<div className="text-xs text-muted-foreground">
<Nip05 pubkey={account.pubkey} profile={profile} />

View File

@@ -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<Map<string, string | number>>(new Map());
const [authenticatingRelays, setAuthenticatingRelays] = useState<Set<string>>(
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
}

View File

@@ -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", {

View File

@@ -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<ISigner, unknown, unknown>) {
const accountType = (account.constructor as unknown as { type: string }).type;
if (accountType === "grimoire-readonly" || accountType === "readonly") {
return (
<Badge variant="secondary" className="text-xs">
<Eye className="size-3 mr-1" />
Read-only
</Badge>
);
}
if (accountType === "extension") {
return (
<Badge variant="secondary" className="text-xs">
<Puzzle className="size-3 mr-1" />
Extension
</Badge>
);
}
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<ISigner, unknown, unknown>;
}) {
const profile = useProfile(account.pubkey);
return (
<div className="flex flex-col gap-0">
<span className="text-sm">{getDisplayName(pubkey, profile)}</span>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm">{getDisplayName(account.pubkey, profile)}</span>
{getAccountTypeBadge(account)}
</div>
{profile ? (
<span className="text-xs text-muted-foreground">
<Nip05 pubkey={pubkey} profile={profile} />
<Nip05 pubkey={account.pubkey} profile={profile} />
</span>
) : null}
</div>
@@ -128,7 +160,7 @@ export default function UserMenu() {
>
<div className="flex items-center gap-2">
<Check className="size-4 text-primary" />
<UserLabel pubkey={account.pubkey} />
<UserLabel account={account} />
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
@@ -149,7 +181,7 @@ export default function UserMenu() {
>
<div className="flex items-center gap-2">
<UserAvatar pubkey={acc.pubkey} />
<UserLabel pubkey={acc.pubkey} />
<UserLabel account={acc} />
</div>
</DropdownMenuItem>
))}