mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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:
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user