Files
bitcoin-wallet/web/WalletSettingsView.tsx
pastilhasandClaude Opus 5 302d8b48c7 wrap chain source, rescan and wallet-rename fields in real forms
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>
2026-08-19 10:42:35 +00:00

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>
);