three input sites lived in a div instead of a form, so pressing enter did nothing and the button was the only way in. chain source url, rescan search depth, and the inline wallet-rename field all get a <form onSubmit>, with every non-submit button in each marked type="button" (the rescan card's reset button was previously untyped, which would have submitted the form once wrapped). CoinsView.tsx has no text inputs (checkbox + buttons only) and SeedPhraseInput.tsx deliberately hijacks enter to advance between word boxes rather than submit, so both are left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
import type { WalletSummary } from './shared';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { Check, Eye, KeyRound, Pencil, Star, Trash2, X } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { KIND_LABELS, walletSectionPath } from './shared';
|
|
import { ChainSourceCard } from './ChainSourceCard';
|
|
import { CopyField } from './CopyField';
|
|
import { EmptyWallet } from './EmptyWallet';
|
|
import { LockBadge } from './LockBadge';
|
|
import { RescanCard } from './RescanCard';
|
|
import { useSelectedWallet } from './useSelectedWallet';
|
|
import { useLockCountdown } from './useLockCountdown';
|
|
import { usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
|
|
import { ChangePassphraseDialog } from './dialogs/ChangePassphraseDialog';
|
|
import { ExportSeedDialog } from './dialogs/ExportSeedDialog';
|
|
import { DeleteWalletDialog } from './dialogs/DeleteWalletDialog';
|
|
|
|
// Wallet-level administration: what this wallet is, what its backend can do, and the three passphrase
|
|
// operations. Nothing here needs the wallet unlocked to *read* — each destructive action asks for the
|
|
// passphrase itself, because an open unlock window is not consent to change or destroy the key material.
|
|
|
|
export const WalletSettingsView = () => {
|
|
const navigate = useNavigate();
|
|
const { wallet, walletId, isLoading } = useSelectedWallet();
|
|
const { config } = useWalletConfig();
|
|
const { capabilities, kind } = usePermissions(walletId);
|
|
const { hasSeed } = useLockCountdown(walletId);
|
|
const { activate, rename } = useWalletLifecycle();
|
|
|
|
const [passphraseOpen, setPassphraseOpen] = useState(false);
|
|
const [exportOpen, setExportOpen] = useState(false);
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
|
|
if (!wallet || walletId == null) return <EmptyWallet isLoading={isLoading} />;
|
|
|
|
const xpubs = Object.entries(wallet.xpubs ?? {});
|
|
|
|
return (
|
|
<div className="h-full space-y-4 overflow-y-auto p-4">
|
|
<section className="max-w-2xl rounded-xl border border-border p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<WalletNameField
|
|
wallet={wallet}
|
|
onSave={(name) => rename.mutateAsync({ walletId, name })}
|
|
isPending={rename.isPending}
|
|
/>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{KIND_LABELS[wallet.kind]} · {wallet.network} · added {wallet.createdAt.slice(0, 10)}
|
|
</p>
|
|
</div>
|
|
<LockBadge walletId={walletId} walletName={wallet.name} />
|
|
</div>
|
|
|
|
<dl className="mt-4 space-y-1.5 text-xs">
|
|
<Row label="Backend">{kind ?? wallet.kind}</Row>
|
|
<Row label="Default derivation">BIP{wallet.defaultBip}</Row>
|
|
{wallet.fingerprint && (
|
|
<Row label="Fingerprint">
|
|
<code className="font-mono">{wallet.fingerprint}</code>
|
|
</Row>
|
|
)}
|
|
<Row label="Active">{wallet.isActive ? 'yes' : 'no'}</Row>
|
|
<Row label="Holds a seed">{hasSeed === true ? 'yes' : hasSeed === false ? 'no' : '—'}</Row>
|
|
</dl>
|
|
|
|
{!wallet.isActive && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="mt-3"
|
|
onClick={() => activate.mutate(wallet.id)}
|
|
disabled={activate.isPending}
|
|
>
|
|
<Star className="mr-1.5 h-3.5 w-3.5" />
|
|
Make this the active wallet
|
|
</Button>
|
|
)}
|
|
</section>
|
|
|
|
{xpubs.length > 0 && (
|
|
<section className="max-w-2xl rounded-xl border border-border p-4">
|
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Account keys</h3>
|
|
<p className="mb-3 text-xs text-muted-foreground">
|
|
Extended public keys. They can watch this wallet but never spend it — safe to hand to a block explorer or an
|
|
accounting tool.
|
|
</p>
|
|
<div className="space-y-2">
|
|
{xpubs.map(([path, xpub]) => (
|
|
<CopyField key={path} value={xpub} label={path} wrap />
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<section className="max-w-2xl rounded-xl border border-border p-4">
|
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
What this backend can do
|
|
</h3>
|
|
{capabilities.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">No capabilities reported.</p>
|
|
) : (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{capabilities.map((cap) => (
|
|
<span key={cap} className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
{cap}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{hasSeed === true && (
|
|
<section className="max-w-2xl rounded-xl border border-border p-4">
|
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Key material</h3>
|
|
<p className="mb-3 text-xs text-muted-foreground">
|
|
Both of these ask for the passphrase on their own, whether or not the wallet is currently unlocked.
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button size="sm" variant="outline" onClick={() => setPassphraseOpen(true)}>
|
|
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
|
|
Change passphrase
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={() => setExportOpen(true)}>
|
|
<Eye className="mr-1.5 h-3.5 w-3.5" />
|
|
Show recovery phrase
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<ChainSourceCard />
|
|
|
|
<RescanCard walletId={walletId} />
|
|
|
|
{config && (
|
|
<section className="max-w-2xl rounded-xl border border-border p-4">
|
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Deployment</h3>
|
|
<dl className="space-y-1.5 text-xs">
|
|
<Row label="Network">{config.network}</Row>
|
|
<Row label="Max unlock window">{formatMinutes(config.unlockTtlSec)}</Row>
|
|
<Row label="Store key">
|
|
{config.storeKeyConfigured ? 'configured' : <span className="text-destructive">missing</span>}
|
|
</Row>
|
|
</dl>
|
|
</section>
|
|
)}
|
|
|
|
<section className="max-w-2xl rounded-xl border border-destructive/40 p-4">
|
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-destructive">Danger</h3>
|
|
<p className="mb-3 text-xs text-muted-foreground">
|
|
{wallet.hasSeed
|
|
? 'Deleting erases the encrypted seed. Only your written recovery phrase can bring the coins back.'
|
|
: 'Deleting removes the connection. The node itself is untouched.'}
|
|
</p>
|
|
<Button size="sm" variant="destructive" onClick={() => setDeleteOpen(true)}>
|
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
|
Delete wallet
|
|
</Button>
|
|
</section>
|
|
|
|
{passphraseOpen && (
|
|
<ChangePassphraseDialog open={passphraseOpen} onOpenChange={setPassphraseOpen} walletId={walletId} />
|
|
)}
|
|
{exportOpen && <ExportSeedDialog open={exportOpen} onOpenChange={setExportOpen} walletId={walletId} />}
|
|
{deleteOpen && (
|
|
<DeleteWalletDialog
|
|
open={deleteOpen}
|
|
onOpenChange={setDeleteOpen}
|
|
wallet={wallet}
|
|
// The deleted id must not stay in `?wallet=` — drop back to the bare section, which means
|
|
// "whatever is active now".
|
|
onDeleted={() => navigate(walletSectionPath('overview'), { replace: true })}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** The wallet's name is the one field here that is purely a label — editing it in place, not in a dialog. */
|
|
const MAX_NAME = 64;
|
|
|
|
type WalletNameFieldProps = {
|
|
wallet: WalletSummary;
|
|
onSave: (name: string) => Promise<unknown>;
|
|
isPending: boolean;
|
|
};
|
|
|
|
const WalletNameField = ({ wallet, onSave, isPending }: WalletNameFieldProps) => {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(wallet.name);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Switching wallets while the editor is open would otherwise rename the NEW wallet to the old one's draft.
|
|
useEffect(() => {
|
|
setEditing(false);
|
|
setDraft(wallet.name);
|
|
}, [wallet.id, wallet.name]);
|
|
|
|
useEffect(() => {
|
|
if (editing) inputRef.current?.select();
|
|
}, [editing]);
|
|
|
|
const trimmed = draft.trim();
|
|
const dirty = trimmed !== wallet.name;
|
|
|
|
const commit = async (ev?: React.FormEvent) => {
|
|
ev?.preventDefault();
|
|
if (!trimmed || !dirty) {
|
|
setDraft(wallet.name);
|
|
setEditing(false);
|
|
return;
|
|
}
|
|
try {
|
|
await onSave(trimmed);
|
|
setEditing(false);
|
|
} catch {
|
|
// The mutation already toasted; keep the editor open so the typed name isn't lost.
|
|
}
|
|
};
|
|
|
|
const cancel = () => {
|
|
setDraft(wallet.name);
|
|
setEditing(false);
|
|
};
|
|
|
|
if (!editing) {
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<h3 className="truncate text-sm font-semibold">{wallet.name}</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(true)}
|
|
aria-label="Rename wallet"
|
|
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={commit} className="flex min-w-0 items-center gap-1.5">
|
|
<Input
|
|
ref={inputRef}
|
|
autoFocus
|
|
value={draft}
|
|
maxLength={MAX_NAME}
|
|
disabled={isPending}
|
|
onChange={(ev) => setDraft(ev.target.value)}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Escape') cancel();
|
|
}}
|
|
className="h-7 max-w-56 text-sm font-semibold"
|
|
aria-label="Wallet name"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
<Button type="submit" size="icon" variant="ghost" className="h-7 w-7" disabled={isPending || !trimmed}>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" onClick={cancel} disabled={isPending}>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const formatMinutes = (seconds: number) => (seconds >= 60 ? `${Math.round(seconds / 60)} min` : `${seconds}s`);
|
|
|
|
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<dt className="shrink-0 text-muted-foreground">{label}</dt>
|
|
<dd className="min-w-0 truncate text-right">{children}</dd>
|
|
</div>
|
|
);
|