mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
feat: add /accounts and /logout commands
Commands Added: - /accounts: Opens account management window showing all accounts - /logout: Removes the active account - /logout --all: Removes all accounts Components Created: 1. AccountManager (src/components/AccountManager.tsx) - Lists all accounts with avatars, names, and public keys - Shows active account with checkmark indicator - Switch account button for inactive accounts - Remove account button with confirmation dialog - Add account button that opens command launcher - Empty state with helpful instructions 2. LogoutHandler (src/components/LogoutHandler.tsx) - Handles logout and logout-all actions - Shows confirmation dialog for logout-all - Displays toast notifications for success/error - Auto-closes after execution Type System Updates: - Added "account-manager" and "logout-handler" to AppId type - Integrated both components with WindowRenderer routing Man Pages: - Added comprehensive documentation for both commands - Includes usage examples and related commands - Proper categorization as System commands
This commit is contained in:
158
src/components/AccountManager.tsx
Normal file
158
src/components/AccountManager.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useObservableMemo } from "applesauce-react/hooks";
|
||||
import { Check, User, UserX, UserPlus } 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Nip05 from "@/components/nostr/nip05";
|
||||
|
||||
function AccountCard({
|
||||
account,
|
||||
isActive,
|
||||
}: {
|
||||
account: any;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const profile = useProfile(account.pubkey);
|
||||
const displayName = getDisplayName(account.pubkey, profile);
|
||||
|
||||
const handleSwitch = () => {
|
||||
accountManager.setActive(account.id);
|
||||
toast.success("Switched account", {
|
||||
description: `Now using ${displayName}`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
const confirmRemove = window.confirm(
|
||||
`Remove account ${displayName}? This cannot be undone.`,
|
||||
);
|
||||
if (confirmRemove) {
|
||||
accountManager.removeAccount(account);
|
||||
toast.success("Account removed", {
|
||||
description: `Removed ${displayName}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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="font-medium truncate">{displayName}</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>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isActive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSwitch}
|
||||
className="cursor-crosshair"
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRemove}
|
||||
className="cursor-crosshair text-destructive hover:text-destructive"
|
||||
title="Remove account"
|
||||
>
|
||||
<UserX className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AccountManager - Shows all accounts with management actions
|
||||
*/
|
||||
export default function AccountManager() {
|
||||
const activeAccount = useObservableMemo(() => accountManager.active$, []);
|
||||
const allAccounts = useObservableMemo(() => accountManager.accounts$, []);
|
||||
const { openCommandLauncher } = useAppShell();
|
||||
|
||||
const handleAddAccount = () => {
|
||||
openCommandLauncher();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto p-6">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Accounts</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddAccount}
|
||||
className="cursor-crosshair"
|
||||
>
|
||||
<UserPlus className="size-4 mr-2" />
|
||||
Add Account
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{allAccounts.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
allAccounts.map((account) => (
|
||||
<AccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
isActive={account.id === activeAccount?.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<p>
|
||||
Tip: You can also switch accounts from the user menu in the
|
||||
top-right corner
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/components/LogoutHandler.tsx
Normal file
86
src/components/LogoutHandler.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LogOut } from "lucide-react";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { useObservableMemo } from "applesauce-react/hooks";
|
||||
|
||||
interface LogoutHandlerProps {
|
||||
action: "logout" | "logout-all";
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* LogoutHandler - Executes logout command actions
|
||||
*
|
||||
* This component handles the result of the /logout command:
|
||||
* - logout: Removes the active account
|
||||
* - logout-all: Removes all accounts
|
||||
*/
|
||||
export default function LogoutHandler({ action, all }: LogoutHandlerProps) {
|
||||
const activeAccount = useObservableMemo(() => accountManager.active$, []);
|
||||
const allAccounts = useObservableMemo(() => accountManager.accounts$, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLogout = () => {
|
||||
if (action === "logout-all" || all) {
|
||||
// Remove all accounts
|
||||
if (allAccounts.length === 0) {
|
||||
toast.info("No accounts to remove", {
|
||||
icon: <LogOut className="h-4 w-4" />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmLogoutAll = window.confirm(
|
||||
`Remove all ${allAccounts.length} account(s)? This cannot be undone.`,
|
||||
);
|
||||
|
||||
if (!confirmLogoutAll) {
|
||||
toast.info("Logout cancelled", {
|
||||
icon: <LogOut className="h-4 w-4" />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all accounts
|
||||
allAccounts.forEach((account) => {
|
||||
accountManager.removeAccount(account);
|
||||
});
|
||||
|
||||
toast.success("All accounts removed", {
|
||||
description: `Removed ${allAccounts.length} account(s)`,
|
||||
icon: <LogOut className="h-4 w-4" />,
|
||||
});
|
||||
} else {
|
||||
// Remove only active account
|
||||
if (!activeAccount) {
|
||||
toast.info("No active account to remove", {
|
||||
description: "You are not logged in",
|
||||
icon: <LogOut className="h-4 w-4" />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
accountManager.removeAccount(activeAccount);
|
||||
|
||||
toast.success("Logged out", {
|
||||
description: `Removed ${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`,
|
||||
icon: <LogOut className="h-4 w-4" />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleLogout();
|
||||
}, [action, all, activeAccount, allAccounts]);
|
||||
|
||||
// This component doesn't render anything visible - it just executes the action
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full p-8">
|
||||
<div className="text-center text-muted-foreground space-y-2">
|
||||
<LogOut className="size-8 mx-auto mb-4 opacity-20" />
|
||||
<p className="text-sm">Processing logout...</p>
|
||||
<p className="text-xs">This window can be closed.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ 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() {
|
||||
@@ -185,6 +187,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
/>
|
||||
);
|
||||
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">
|
||||
|
||||
@@ -19,7 +19,9 @@ export type AppId =
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "win"
|
||||
| "login-handler";
|
||||
| "login-handler"
|
||||
| "account-manager"
|
||||
| "logout-handler";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
|
||||
@@ -140,6 +140,43 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
},
|
||||
defaultProps: { action: "open-dialog" },
|
||||
},
|
||||
accounts: {
|
||||
name: "accounts",
|
||||
section: "1",
|
||||
synopsis: "accounts",
|
||||
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"],
|
||||
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