feat: transition to UI-exclusive login with dialog interface

- Remove login and logout commands from command palette
- Create LoginDialog component with tabbed interface (Read-only and Extension)
- Integrate LoginDialog with user menu and AccountManager
- Update badge styling to be more subtle with outline variant
- Remove avatars from user menu and account manager
- Update account layout to use space-between for name and badge
- Remove unused LoginHandler and LogoutHandler components
This commit is contained in:
Claude
2026-01-05 16:20:26 +00:00
parent 91afc1b251
commit 80aa5c9adb
6 changed files with 177 additions and 168 deletions

View File

@@ -1,15 +1,15 @@
import { useState } from "react";
import { useObservableMemo } from "applesauce-react/hooks";
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 LoginDialog from "@/components/LoginDialog";
import type { IAccount } from "applesauce-accounts";
import type { ISigner } from "applesauce-signers";
@@ -18,7 +18,7 @@ function getAccountTypeBadge(account: IAccount<ISigner, unknown, unknown>) {
if (accountType === "grimoire-readonly" || accountType === "readonly") {
return (
<Badge variant="secondary" className="text-xs">
<Badge variant="outline" className="text-xs text-muted-foreground border-muted">
<Eye className="size-3 mr-1" />
Read-only
</Badge>
@@ -27,7 +27,7 @@ function getAccountTypeBadge(account: IAccount<ISigner, unknown, unknown>) {
if (accountType === "extension") {
return (
<Badge variant="secondary" className="text-xs">
<Badge variant="outline" className="text-xs text-muted-foreground border-muted">
<Puzzle className="size-3 mr-1" />
Extension
</Badge>
@@ -70,29 +70,23 @@ function AccountCard({
<Card className={isActive ? "border-primary" : ""}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
{isActive && (
<Check className="size-4 text-primary flex-shrink-0" />
)}
<Avatar className="size-10">
<AvatarImage src={profile?.picture} alt={displayName} />
<AvatarFallback>
<User className="size-5" />
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{isActive && (
<Check className="size-4 text-primary flex-shrink-0" />
)}
<div className="flex items-center justify-between gap-3 flex-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} />
</div>
)}
<div className="text-xs text-muted-foreground font-mono truncate">
{account.pubkey.slice(0, 8)}...{account.pubkey.slice(-8)}
</div>
{profile && (
<div className="text-xs text-muted-foreground">
<Nip05 pubkey={account.pubkey} profile={profile} />
</div>
)}
<div className="text-xs text-muted-foreground font-mono truncate">
{account.pubkey.slice(0, 8)}...{account.pubkey.slice(-8)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -128,14 +122,15 @@ function AccountCard({
export default function AccountManager() {
const activeAccount = useObservableMemo(() => accountManager.active$, []);
const allAccounts = useObservableMemo(() => accountManager.accounts$, []);
const { openCommandLauncher } = useAppShell();
const [showLoginDialog, setShowLoginDialog] = useState(false);
const handleAddAccount = () => {
openCommandLauncher();
setShowLoginDialog(true);
};
return (
<div className="h-full w-full overflow-auto p-6">
<LoginDialog open={showLoginDialog} onOpenChange={setShowLoginDialog} />
<div className="max-w-3xl mx-auto space-y-6">
<Card>
<CardHeader>
@@ -157,11 +152,7 @@ export default function AccountManager() {
<User className="size-12 mx-auto mb-4 opacity-20" />
<p className="text-sm">No accounts yet</p>
<p className="text-xs mt-2">
Use the "Add Account" button or type{" "}
<code className="text-xs px-1 py-0.5 bg-muted rounded">
login
</code>{" "}
in the command launcher
Click "Add Account" to get started
</p>
</div>
) : (

View File

@@ -0,0 +1,142 @@
import { useState } from "react";
import { toast } from "sonner";
import { Eye, Puzzle } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import accountManager from "@/services/accounts";
import { ExtensionSigner } from "applesauce-signers";
import { ExtensionAccount } from "applesauce-accounts/accounts";
import { createAccountFromInput } from "@/lib/login-parser";
interface LoginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
const [readonlyInput, setReadonlyInput] = useState("");
const [loading, setLoading] = useState(false);
const handleReadonlyLogin = async () => {
if (!readonlyInput.trim()) {
toast.error("Please enter an identifier");
return;
}
setLoading(true);
try {
const account = await createAccountFromInput(readonlyInput);
accountManager.addAccount(account);
accountManager.setActive(account.id);
toast.success("Account added successfully");
onOpenChange(false);
setReadonlyInput("");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to add account",
);
} finally {
setLoading(false);
}
};
const handleExtensionLogin = async () => {
setLoading(true);
try {
const signer = new ExtensionSigner();
const pubkey = await signer.getPublicKey();
const account = new ExtensionAccount(pubkey, signer);
accountManager.addAccount(account);
accountManager.setActive(account.id);
toast.success("Connected to extension");
onOpenChange(false);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to connect to extension",
);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Add Account</DialogTitle>
</DialogHeader>
<Tabs defaultValue="readonly" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="readonly" className="gap-2">
<Eye className="size-4" />
Read-only
</TabsTrigger>
<TabsTrigger value="extension" className="gap-2">
<Puzzle className="size-4" />
Extension
</TabsTrigger>
</TabsList>
<TabsContent value="readonly" className="space-y-4 pt-4">
<div className="space-y-2">
<label htmlFor="identifier" className="text-sm font-medium">
Identifier
</label>
<Input
id="identifier"
placeholder="npub1..., user@domain.com, hex, or nprofile1..."
value={readonlyInput}
onChange={(e) => setReadonlyInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleReadonlyLogin();
}}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
Supports npub, NIP-05, hex pubkey, or nprofile
</p>
</div>
<Button
onClick={handleReadonlyLogin}
disabled={loading || !readonlyInput.trim()}
className="w-full"
>
{loading ? "Adding..." : "Add Read-only Account"}
</Button>
</TabsContent>
<TabsContent value="extension" className="space-y-4 pt-4">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Connect to your browser extension to sign events and encrypt
messages.
</p>
<p className="text-xs text-muted-foreground">
Supports Alby, nos2x, and other NIP-07 compatible extensions.
</p>
</div>
<Button
onClick={handleExtensionLogin}
disabled={loading}
className="w-full"
>
{loading ? "Connecting..." : "Connect Extension"}
</Button>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -33,9 +33,7 @@ const SpellsViewer = lazy(() =>
const SpellbooksViewer = lazy(() =>
import("./SpellbooksViewer").then((m) => ({ default: m.SpellbooksViewer })),
);
const LoginHandler = lazy(() => import("./LoginHandler"));
const AccountManager = lazy(() => import("./AccountManager"));
const LogoutHandler = lazy(() => import("./LogoutHandler"));
// Loading fallback component
function ViewerLoading() {
@@ -178,23 +176,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "spellbooks":
content = <SpellbooksViewer />;
break;
case "login-handler":
content = (
<LoginHandler
action={window.props.action}
account={window.props.account}
message={window.props.message}
/>
);
break;
case "account-manager":
content = <AccountManager />;
break;
case "logout-handler":
content = (
<LogoutHandler action={window.props.action} all={window.props.all} />
);
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -1,12 +1,9 @@
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";
import { useProfile } from "@/hooks/useProfile";
import { useObservableMemo } from "applesauce-react/hooks";
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 {
@@ -18,10 +15,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Nip05 from "./nip05";
import { RelayLink } from "./RelayLink";
import SettingsDialog from "@/components/SettingsDialog";
import LoginDialog from "@/components/LoginDialog";
import { useState } from "react";
import type { IAccount } from "applesauce-accounts";
import type { ISigner } from "applesauce-signers";
@@ -31,7 +28,7 @@ function getAccountTypeBadge(account: IAccount<ISigner, unknown, unknown>) {
if (accountType === "grimoire-readonly" || accountType === "readonly") {
return (
<Badge variant="secondary" className="text-xs">
<Badge variant="outline" className="text-xs text-muted-foreground border-muted">
<Eye className="size-3 mr-1" />
Read-only
</Badge>
@@ -40,7 +37,7 @@ function getAccountTypeBadge(account: IAccount<ISigner, unknown, unknown>) {
if (accountType === "extension") {
return (
<Badge variant="secondary" className="text-xs">
<Badge variant="outline" className="text-xs text-muted-foreground border-muted">
<Puzzle className="size-3 mr-1" />
Extension
</Badge>
@@ -50,21 +47,6 @@ function getAccountTypeBadge(account: IAccount<ISigner, unknown, unknown>) {
return null;
}
function UserAvatar({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
return (
<Avatar className="size-4">
<AvatarImage
src={profile?.picture}
alt={getDisplayName(pubkey, profile)}
/>
<AvatarFallback>
{getDisplayName(pubkey, profile).slice(2)}
</AvatarFallback>
</Avatar>
);
}
function UserLabel({
account,
}: {
@@ -73,8 +55,8 @@ function UserLabel({
const profile = useProfile(account.pubkey);
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm">{getDisplayName(account.pubkey, profile)}</span>
<div className="flex items-center justify-between gap-3 w-full">
<span className="text-sm truncate">{getDisplayName(account.pubkey, profile)}</span>
{getAccountTypeBadge(account)}
</div>
{profile ? (
@@ -90,9 +72,9 @@ export default function UserMenu() {
const account = useObservableMemo(() => accounts.active$, []);
const allAccounts = useObservableMemo(() => accounts.accounts$, []);
const { state, addWindow } = useGrimoire();
const { openCommandLauncher } = useAppShell();
const relays = state.activeAccount?.relays;
const [showSettings, setShowSettings] = useState(false);
const [showLoginDialog, setShowLoginDialog] = useState(false);
// Get other accounts (not the active one)
const otherAccounts = allAccounts.filter((acc) => acc.id !== account?.id);
@@ -106,16 +88,8 @@ export default function UserMenu() {
);
}
async function login() {
try {
const signer = new ExtensionSigner();
const pubkey = await signer.getPublicKey();
const account = new ExtensionAccount(pubkey, signer);
accounts.addAccount(account);
accounts.setActive(account);
} catch (err) {
console.error(err);
}
function login() {
setShowLoginDialog(true);
}
function switchAccount(targetAccount: IAccount<ISigner, unknown, unknown>) {
@@ -123,8 +97,7 @@ export default function UserMenu() {
}
function addAccount() {
// Open the command launcher (user will type "login" command)
openCommandLauncher();
setShowLoginDialog(true);
}
async function logout() {
@@ -135,6 +108,7 @@ export default function UserMenu() {
return (
<>
<SettingsDialog open={showSettings} onOpenChange={setShowSettings} />
<LoginDialog open={showLoginDialog} onOpenChange={setShowLoginDialog} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -142,11 +116,7 @@ export default function UserMenu() {
variant="link"
aria-label={account ? "User menu" : "Log in"}
>
{account ? (
<UserAvatar pubkey={account.pubkey} />
) : (
<User onClick={login} className="size-4 text-muted-foreground" />
)}
<User className="size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80" align="start">
@@ -179,10 +149,7 @@ export default function UserMenu() {
onClick={() => switchAccount(acc)}
className="cursor-crosshair"
>
<div className="flex items-center gap-2">
<UserAvatar pubkey={acc.pubkey} />
<UserLabel account={acc} />
</div>
<UserLabel account={acc} />
</DropdownMenuItem>
))}
</DropdownMenuGroup>

View File

@@ -19,9 +19,7 @@ export type AppId =
| "spells"
| "spellbooks"
| "win"
| "login-handler"
| "account-manager"
| "logout-handler";
| "account-manager";
export interface WindowInstance {
id: string;

View File

@@ -5,7 +5,6 @@ import { parseOpenCommand } from "@/lib/open-parser";
import { parseProfileCommand } from "@/lib/profile-parser";
import { parseRelayCommand } from "@/lib/relay-parser";
import { resolveNip05Batch } from "@/lib/nip05";
import { createAccountFromInput } from "@/lib/login-parser";
export interface ManPageEntry {
name: string;
@@ -93,53 +92,6 @@ export const manPages: Record<string, ManPageEntry> = {
category: "System",
defaultProps: { cmd: "help" },
},
login: {
name: "login",
section: "1",
synopsis: "login [identifier]",
description:
"Add a new Nostr account to Grimoire. Supports read-only accounts (npub, nip-05, hex pubkey, nprofile) and signing accounts (browser extension via NIP-07). When called without arguments, opens the login dialog with method selection.",
options: [
{
flag: "[identifier]",
description:
"Account identifier: npub1..., user@domain.com, hex pubkey, nprofile1..., or leave empty to open dialog",
},
],
examples: [
"login Open login dialog",
"login npub1abc... Add read-only account from npub",
"login alice@nostr.com Add read-only account from NIP-05",
"login nprofile1... Add read-only account with relay hints",
"login 3bf0c63f... Add read-only account from hex pubkey",
],
seeAlso: ["profile", "req"],
appId: "login-handler",
category: "System",
argParser: async (args: string[]) => {
const input = args.join(" ").trim();
// No input - open dialog
if (!input) {
return { action: "open-dialog" };
}
// Try to create account from input
try {
const account = await createAccountFromInput(input);
return {
action: "add-account",
account,
};
} catch (error) {
return {
action: "error",
message: error instanceof Error ? error.message : "Unknown error",
};
}
},
defaultProps: { action: "open-dialog" },
},
accounts: {
name: "accounts",
section: "1",
@@ -147,36 +99,11 @@ export const manPages: Record<string, ManPageEntry> = {
description:
"View and manage all Nostr accounts in Grimoire. Shows all logged-in accounts with their connection types, allows switching between accounts, and provides account management options.",
examples: ["accounts View all accounts"],
seeAlso: ["login", "logout", "profile"],
seeAlso: ["profile"],
appId: "account-manager",
category: "System",
defaultProps: {},
},
logout: {
name: "logout",
section: "1",
synopsis: "logout [--all]",
description:
"Remove the active Nostr account from Grimoire. Use the --all flag to remove all accounts at once.",
options: [
{
flag: "--all",
description: "Remove all accounts instead of just the active one",
},
],
examples: [
"logout Remove the active account",
"logout --all Remove all accounts",
],
seeAlso: ["login", "accounts"],
appId: "logout-handler",
category: "System",
argParser: (args: string[]) => {
const all = args.includes("--all");
return { action: all ? "logout-all" : "logout", all };
},
defaultProps: { action: "logout", all: false },
},
kinds: {
name: "kinds",
section: "1",