mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 08:57:04 +02:00
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:
@@ -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>
|
||||
) : (
|
||||
|
||||
142
src/components/LoginDialog.tsx
Normal file
142
src/components/LoginDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,9 +19,7 @@ export type AppId =
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "win"
|
||||
| "login-handler"
|
||||
| "account-manager"
|
||||
| "logout-handler";
|
||||
| "account-manager";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user