Tenth plugin out, and the one whose extraction turned on a permission decision rather than a mechanical move. The wallet permission was kind: 'admin' — owner only, not grantable, invisible to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts forces it and the manifest has no field for anything else, deliberately, so that "can a plugin escalate itself" is a question which cannot be asked. So extraction could not preserve admin, and that was raised as a blocker before any code moved. It was the right change anyway, and the objection inverts. Every wallet query is scoped by user_id, and that id comes from X-Officer-User which the platform injects from the authenticated session — a caller cannot name someone else's. So granting this gives an account ITS OWN wallet. Not through the API and not with root either does it hand over anyone else's, because their seed is sealed under a passphrase the server never stores. kind: 'admin' was single-user thinking; grantable is how a second person gets a wallet at all. Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically, and the appName drives the route, the API prefix, the table prefix and the install key together. Tables renamed to bitcoin_wallet_* accordingly — the prefix guard normalises the hyphen. Free only because no wallet table existed on any server, which was checked before deciding: db:push treats a rename as a drop plus a create. The three custody test files moved with it and still run — 72 tests over scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by nine, so that trap was already closed. 68 palette classes converted, five status vocabularies hoisted into shared.ts. Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all read as warning, and the bitcoin and lightning rails no longer differ by hue. Deferred on purpose: watch-only data (xpubs, balances, history) stays unencrypted so a locked wallet still shows a balance. Anyone with the database can therefore SEE any user's balances; they can never move a coin.
560 lines
27 KiB
TypeScript
560 lines
27 KiB
TypeScript
import type { BackendKind } from '../shared';
|
|
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { Loader2, TriangleAlert } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { KIND_LABELS, walletSectionPath, type WalletSectionId } from '../shared';
|
|
import { errorMessage, useWalletConfig, useWalletLifecycle } from '../useWalletData';
|
|
import { SeedBackupDialog } from './SeedBackupDialog';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
SeedPhraseInput,
|
|
SEED_LENGTHS,
|
|
findOrderFix,
|
|
isAezeed,
|
|
normalizePhrase,
|
|
phraseProblem,
|
|
type SeedLength,
|
|
} from './SeedPhraseInput';
|
|
|
|
// Add a wallet. Two genuinely different shapes behind one form:
|
|
//
|
|
// onchain — self-custodial. A seed is generated (or imported) and sealed under a passphrase that only
|
|
// ever exists in this component's state and the request body. Generating returns the mnemonic
|
|
// exactly once, which is why this dialog hands straight over to SeedBackupDialog and does not
|
|
// close until the owner confirms they wrote it down.
|
|
// everything else — a connection to somebody's node or account, so it is a config blob, no seed at all.
|
|
//
|
|
// Wallet creation is refused outright when the wallet's at-rest key is unreachable. The form says so up front
|
|
// rather than letting the submit fail with a crypto error.
|
|
|
|
type CreateWalletDialogProps = {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
/** Where to land after creating — keeps the new wallet's id in the URL. */
|
|
section: WalletSectionId;
|
|
};
|
|
|
|
const KINDS: BackendKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
|
|
|
|
/** The connection fields each remote backend needs, spelled once. */
|
|
const CONFIG_FIELDS: Record<Exclude<BackendKind, 'onchain'>, { key: string; label: string; placeholder: string }[]> = {
|
|
lnd: [
|
|
{ key: 'url', label: 'REST URL', placeholder: 'https://node.local:8080' },
|
|
{ key: 'macaroon', label: 'Admin macaroon (hex)', placeholder: '0201036c6e64…' },
|
|
],
|
|
'cln-rest': [
|
|
{ key: 'url', label: 'CLNRest URL', placeholder: 'https://node.local:3010' },
|
|
{ key: 'rune', label: 'Rune', placeholder: 'ZW5jcnlwdGVkOg…' },
|
|
],
|
|
lndhub: [
|
|
{ key: 'url', label: 'LNDHub URL', placeholder: 'https://lndhub.io' },
|
|
{ key: 'login', label: 'Login', placeholder: 'lndhub login' },
|
|
{ key: 'password', label: 'Password', placeholder: 'lndhub password' },
|
|
],
|
|
nwc: [{ key: 'connectionString', label: 'Connection string', placeholder: 'nostr+walletconnect://…' }],
|
|
};
|
|
|
|
export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWalletDialogProps) => {
|
|
const navigate = useNavigate();
|
|
const { config } = useWalletConfig();
|
|
const { create } = useWalletLifecycle();
|
|
|
|
const [name, setName] = useState('');
|
|
const [kind, setKind] = useState<BackendKind>('onchain');
|
|
const [words, setWords] = useState<12 | 24>(24);
|
|
const [importing, setImporting] = useState(false);
|
|
// One entry per word rather than one string: the boxes ARE the state, so a wrong word is visible where
|
|
// it is rather than somewhere in a wall of text. Joined back into a phrase only at submit.
|
|
const [importLength, setImportLength] = useState<SeedLength>(24);
|
|
const [importWords, setImportWords] = useState<string[]>(() => Array(24).fill(''));
|
|
// A plain-text escape hatch alongside the boxes. Both write the same `importWords`, so validation and
|
|
// submit never care which was used — but when a phrase the owner knows is right will not validate, this
|
|
// is what separates "my per-box entry mangled it" from "the phrase really is different".
|
|
const [asText, setAsText] = useState(false);
|
|
const [importText, setImportText] = useState('');
|
|
// Both of these are secrets in flight. They exist in this component's state, go into the request body,
|
|
// and are cleared the moment the request settles — never a store, never a query key, never the URL.
|
|
const [passphrase, setPassphrase] = useState('');
|
|
const [confirmPassphrase, setConfirmPassphrase] = useState('');
|
|
const [bip39Passphrase, setBip39Passphrase] = useState('');
|
|
const [makeActive, setMakeActive] = useState(true);
|
|
const [remoteConfig, setRemoteConfig] = useState<Record<string, string>>({});
|
|
|
|
// The one mnemonic the sidecar will ever hand back. Held here only until the backup modal is confirmed.
|
|
const [pendingSeed, setPendingSeed] = useState<{ mnemonic: string; walletName: string; walletId: number } | null>(
|
|
null,
|
|
);
|
|
|
|
const storeKeyMissing = config != null && !config.storeKeyConfigured;
|
|
const isOnchain = kind === 'onchain';
|
|
|
|
/** Anything the owner would be upset to retype — guards the incidental-dismiss paths below. */
|
|
const hasTypedSecret =
|
|
importWords.some((w) => w.trim()) || passphrase.length > 0 || Object.values(remoteConfig).some((v) => v?.trim());
|
|
|
|
/** Grow or shrink the grid, keeping whatever has already been typed. */
|
|
const resizeImport = (n: SeedLength) => {
|
|
setImportLength(n);
|
|
setImportWords((prev) => Array.from({ length: n }, (_, i) => prev[i] ?? ''));
|
|
};
|
|
|
|
/** Text mode is the source of truth while it is open; the boxes mirror it word for word. */
|
|
const applyText = (text: string) => {
|
|
setImportText(text);
|
|
const parts = normalizePhrase(text);
|
|
if ((SEED_LENGTHS as readonly number[]).includes(parts.length)) setImportLength(parts.length as SeedLength);
|
|
setImportWords(parts);
|
|
};
|
|
|
|
/** Switching modes must not silently drop or reorder anything the owner already entered. */
|
|
const toggleTextMode = (next: boolean) => {
|
|
if (next) setImportText(importWords.filter(Boolean).join(' '));
|
|
else setImportWords((prev) => Array.from({ length: importLength }, (_, i) => prev[i] ?? ''));
|
|
setAsText(next);
|
|
};
|
|
|
|
const clearSecrets = () => {
|
|
setPassphrase('');
|
|
setConfirmPassphrase('');
|
|
setBip39Passphrase('');
|
|
// Functional, so it blanks whatever length the grid currently is without reading stale state.
|
|
setImportWords((prev) => prev.map(() => ''));
|
|
setImportText('');
|
|
setRemoteConfig({});
|
|
};
|
|
|
|
const reset = () => {
|
|
setName('');
|
|
setKind('onchain');
|
|
setWords(24);
|
|
setImporting(false);
|
|
setMakeActive(true);
|
|
clearSecrets();
|
|
setAsText(false);
|
|
setImportLength(24);
|
|
setImportWords(Array(24).fill(''));
|
|
};
|
|
|
|
const close = () => {
|
|
reset();
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const passphraseValid = !isOnchain || (passphrase.length >= 8 && passphrase === confirmPassphrase);
|
|
const configValid =
|
|
isOnchain || CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].every((f) => remoteConfig[f.key]?.trim());
|
|
// Checked locally so a bad phrase never costs a round trip. The sidecar still validates independently;
|
|
// this only means the owner finds out instantly, and precisely, instead of via a generic 400.
|
|
const seedProblem = isOnchain && importing ? phraseProblem(importWords) : null;
|
|
// Only worth computing for the one failure a human review cannot catch: right words, wrong order.
|
|
const orderFix = seedProblem?.kind === 'checksum' ? findOrderFix(importWords) : null;
|
|
// A valid phrase can be either format, and the two are visually identical, so say which one this is.
|
|
// It also changes what the passphrase field below means, which the owner has no way to guess.
|
|
const aezeed = isOnchain && importing && !seedProblem && isAezeed(importWords.map((w) => w.trim().toLowerCase()));
|
|
const canSubmit =
|
|
!!name.trim() && passphraseValid && configValid && !seedProblem && !storeKeyMissing && !create.isPending;
|
|
|
|
// A disabled button with no stated reason is a dead end — especially here, where the blocker is often
|
|
// the passphrase fields further down while the owner is staring at the phrase.
|
|
const blockReason = !name.trim()
|
|
? 'Give the wallet a name.'
|
|
: seedProblem
|
|
? 'The recovery phrase above is not complete or not valid.'
|
|
: !passphraseValid
|
|
? 'Set a passphrase of at least 8 characters, twice.'
|
|
: !configValid
|
|
? 'Fill in the connection details.'
|
|
: null;
|
|
|
|
const submit = async (ev: React.FormEvent) => {
|
|
ev.preventDefault();
|
|
if (!canSubmit) return;
|
|
|
|
let result;
|
|
try {
|
|
result = await create.mutateAsync(
|
|
isOnchain
|
|
? {
|
|
name: name.trim(),
|
|
kind,
|
|
passphrase,
|
|
words,
|
|
mnemonic: importing ? importWords.map((w) => w.trim()).join(' ') : undefined,
|
|
bip39Passphrase: bip39Passphrase || undefined,
|
|
makeActive,
|
|
}
|
|
: { name: name.trim(), kind, config: remoteConfig, makeActive },
|
|
);
|
|
} catch {
|
|
// KEEP EVERYTHING TYPED. A rejected phrase is precisely the moment the owner needs their other 23
|
|
// words back — clearing here means retyping the whole thing to fix one box, which is both miserable
|
|
// and a good way to introduce a second mistake. `create.onError` already toasts, and the error is
|
|
// rendered inline below. Nothing lingers regardless: cancel or close still runs clearSecrets().
|
|
return;
|
|
}
|
|
|
|
const created = result.wallet;
|
|
if (result.mnemonic) {
|
|
// Generated seed: hold the words for the backup modal and keep this dialog mounted underneath.
|
|
setPendingSeed({ mnemonic: result.mnemonic, walletName: created.name, walletId: created.id });
|
|
clearSecrets();
|
|
} else {
|
|
navigate(walletSectionPath(section, created.id));
|
|
close();
|
|
}
|
|
};
|
|
|
|
const seedConfirmed = () => {
|
|
const created = pendingSeed;
|
|
setPendingSeed(null);
|
|
close();
|
|
if (created) navigate(walletSectionPath(section, created.walletId));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open && pendingSeed == null} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
|
{/* Wider only while importing — 24 word boxes at max-w-lg are two cramped columns. */}
|
|
<DialogContent
|
|
className={`max-h-[85vh] overflow-y-auto ${isOnchain && importing ? 'max-w-2xl' : 'max-w-lg'}`}
|
|
// A half-typed recovery phrase must not be destroyed by a misplaced click on the overlay or a
|
|
// stray Escape (dismissing an error toast, say). Those gestures are cheap and reversible for an
|
|
// ordinary form; here they cost the owner all 24 words. Cancel and the X still close normally —
|
|
// the point is that discarding becomes deliberate, not incidental.
|
|
onInteractOutside={(ev) => {
|
|
if (hasTypedSecret) ev.preventDefault();
|
|
}}
|
|
onEscapeKeyDown={(ev) => {
|
|
if (hasTypedSecret) ev.preventDefault();
|
|
}}
|
|
>
|
|
<form onSubmit={submit}>
|
|
<DialogHeader>
|
|
<DialogTitle>Add a wallet</DialogTitle>
|
|
<DialogDescription>
|
|
Self-custodial keys stay in the wallet sidecar; a remote node is only a stored connection.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{storeKeyMissing && (
|
|
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
|
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
<span>
|
|
<span className="font-medium">The secret store is unreachable.</span> The sidecar refuses to store
|
|
wallet secrets without it, so creation is disabled until it is set.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="wallet-new-name">Name</Label>
|
|
<Input
|
|
id="wallet-new-name"
|
|
value={name}
|
|
autoFocus
|
|
placeholder="Savings"
|
|
onChange={(ev) => setName(ev.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="wallet-new-kind">Type</Label>
|
|
<Select value={kind} onValueChange={(v) => setKind(v as BackendKind)}>
|
|
<SelectTrigger id="wallet-new-kind">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{KINDS.map((k) => (
|
|
<SelectItem key={k} value={k}>
|
|
{KIND_LABELS[k]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{config && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Network: <span className="font-medium">{config.network}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{isOnchain ? (
|
|
<>
|
|
<div className="flex gap-1.5">
|
|
<SegButton active={!importing} onClick={() => setImporting(false)} label="Generate a new seed" />
|
|
<SegButton active={importing} onClick={() => setImporting(true)} label="Import a phrase" />
|
|
</div>
|
|
|
|
{importing ? (
|
|
<div className="space-y-1.5">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<Label>Recovery phrase</Label>
|
|
<SegButton
|
|
active={asText}
|
|
onClick={() => toggleTextMode(!asText)}
|
|
label={asText ? 'Use word boxes' : 'Paste as text'}
|
|
/>
|
|
</div>
|
|
|
|
{!asText && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{SEED_LENGTHS.map((n) => (
|
|
<SegButton
|
|
key={n}
|
|
active={importLength === n}
|
|
onClick={() => resizeImport(n)}
|
|
label={`${n}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Secret in flight: state → request body → cleared. Never stored on this side, and
|
|
never echoed back by the sidecar either. */}
|
|
{asText ? (
|
|
<Textarea
|
|
rows={4}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
data-1p-ignore
|
|
data-lpignore="true"
|
|
value={importText}
|
|
placeholder="Paste or type the whole phrase. Numbering, commas and line breaks are ignored."
|
|
onChange={(ev) => applyText(ev.target.value)}
|
|
className="font-mono text-xs"
|
|
/>
|
|
) : (
|
|
<SeedPhraseInput
|
|
words={importWords}
|
|
onChange={setImportWords}
|
|
onLengthDetected={(n) => setImportLength(n as SeedLength)}
|
|
/>
|
|
)}
|
|
{aezeed ? (
|
|
<div className="space-y-1.5 rounded-lg border border-success/40 bg-success/5 p-3">
|
|
<p className="text-xs">
|
|
<span className="font-medium text-success">
|
|
Valid aezeed seed — checksum verified in this browser.
|
|
</span>{' '}
|
|
This is not a BIP39 phrase. It is an <em>aezeed</em> cipher seed, the format Zeus's embedded
|
|
LND node produces: the same 2048-word list, so it looks identical, but encrypted and
|
|
checksummed differently. Officer imports it.
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
This recovers the <span className="font-medium">on-chain</span> wallet. Lightning channels
|
|
are not in the seed — those need a channel backup file and a running LND.
|
|
</p>
|
|
</div>
|
|
) : orderFix ? (
|
|
<div className="space-y-1.5 rounded-lg border border-warning/40 bg-warning/5 p-3">
|
|
<p className="text-xs">
|
|
<span className="font-medium">These are your words — the order is wrong.</span> Read down
|
|
the columns, they check out. Your backup is almost certainly printed as {orderFix.rows} rows
|
|
of {orderFix.cols}, running top-to-bottom, and was read left-to-right.
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setImportWords(orderFix.words);
|
|
setImportText(orderFix.words.join(' '));
|
|
}}
|
|
>
|
|
Reorder to the valid phrase
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">
|
|
Check the result against your backup before continuing.
|
|
</p>
|
|
</div>
|
|
) : seedProblem?.kind === 'checksum' ? (
|
|
// The hardest case to act on, so it gets the longest explanation: every word is
|
|
// real, so nothing is marked red, and the only remaining faults are order or a
|
|
// word that is a near-miss for the right one.
|
|
<p className="text-xs text-destructive">
|
|
All {importWords.length} words are valid BIP39 words, but the phrase does not check out. A
|
|
word is in the wrong position, or one is a near-miss for the right one (<em>fan</em> where the
|
|
backup says <em>fancy</em>). Re-read it against your backup in order.
|
|
</p>
|
|
) : seedProblem?.kind === 'unknown' ? (
|
|
<p className="text-xs text-destructive">
|
|
Not in the BIP39 word list: {seedProblem.boxes.length === 1 ? 'box' : 'boxes'}{' '}
|
|
{seedProblem.boxes.join(', ')}.
|
|
</p>
|
|
) : seedProblem?.kind === 'incomplete' ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{seedProblem.boxes.length} {seedProblem.boxes.length === 1 ? 'word' : 'words'} still empty.
|
|
</p>
|
|
) : seedProblem?.kind === 'length' ? (
|
|
<p className="text-xs text-destructive">
|
|
{seedProblem.count} words. A BIP39 phrase must be 12, 15, 18, 21 or 24.
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-success">
|
|
Valid phrase — checksum verified in this browser.
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
Type a few letters and pick from the list, or paste the whole phrase into the first box.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
<Label>Phrase length</Label>
|
|
<div className="flex gap-1.5">
|
|
<SegButton active={words === 12} onClick={() => setWords(12)} label="12 words" />
|
|
<SegButton active={words === 24} onClick={() => setWords(24)} label="24 words" />
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
The phrase is shown once, immediately after creation, and never again without this passphrase.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="wallet-new-pass">Passphrase</Label>
|
|
{/* Seals the seed. Straight into the request body, cleared on settle — no store. */}
|
|
<Input
|
|
id="wallet-new-pass"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={passphrase}
|
|
onChange={(ev) => setPassphrase(ev.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="wallet-new-pass2">Confirm</Label>
|
|
<Input
|
|
id="wallet-new-pass2"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={confirmPassphrase}
|
|
onChange={(ev) => setConfirmPassphrase(ev.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{passphrase.length > 0 && passphrase.length < 8 && (
|
|
<p className="text-xs text-destructive">Use at least 8 characters.</p>
|
|
)}
|
|
{confirmPassphrase.length > 0 && passphrase !== confirmPassphrase && (
|
|
<p className="text-xs text-destructive">The two passphrases do not match.</p>
|
|
)}
|
|
|
|
{/* One field, two meanings, because the two seed formats each have exactly one such
|
|
secret and which one applies is decided by the phrase itself. Mislabelling it for an
|
|
aezeed would be actively harmful: leaving it blank there is correct and normal, while
|
|
typing the wallet passphrase into it fails the AEZ tag and looks like a bad seed. */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="wallet-new-bip39">
|
|
{aezeed ? 'Seed passphrase (optional)' : 'BIP39 passphrase (optional)'}
|
|
</Label>
|
|
{/* Part of the seed itself — lose it and the coins are gone. Same handling: no store. */}
|
|
<Input
|
|
id="wallet-new-bip39"
|
|
type="password"
|
|
autoComplete="off"
|
|
value={bip39Passphrase}
|
|
onChange={(ev) => setBip39Passphrase(ev.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{aezeed
|
|
? 'Only if you set one when the aezeed seed was created. Almost nobody does — leave it blank.'
|
|
: 'A 25th word. It is part of the key, not a lock on it — without it the phrase alone recovers a different, empty wallet.'}
|
|
</p>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].map((field) => (
|
|
<div key={field.key} className="space-y-1.5">
|
|
<Label htmlFor={`wallet-new-${field.key}`}>{field.label}</Label>
|
|
{/* Node credentials are stored server-side, encrypted, and never returned to the
|
|
browser — so this input is write-only and holds nothing after submit. */}
|
|
<Input
|
|
id={`wallet-new-${field.key}`}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
value={remoteConfig[field.key] ?? ''}
|
|
placeholder={field.placeholder}
|
|
onChange={(ev) => setRemoteConfig((prev) => ({ ...prev, [field.key]: ev.target.value }))}
|
|
className="font-mono text-xs"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<label className="flex items-center gap-2.5 text-sm">
|
|
<Checkbox checked={makeActive} onCheckedChange={(v) => setMakeActive(v === true)} />
|
|
Make this the active wallet
|
|
</label>
|
|
</div>
|
|
|
|
{/* The toast disappears; this does not. With the form no longer clearing on failure, the
|
|
owner needs the reason to stay put next to the boxes they are about to correct. */}
|
|
{/* Only while the current input still matches what was rejected. Without this the box from a
|
|
previous attempt hangs around after the phrase is corrected, which reads as "still broken"
|
|
when nothing is broken — and the mutation's error state does not clear itself. */}
|
|
{create.isError && !seedProblem && (
|
|
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
|
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
<span>{errorMessage(create.error, 'Could not create the wallet')}</span>
|
|
</div>
|
|
)}
|
|
|
|
{blockReason && !storeKeyMissing && (
|
|
<p className="mt-4 text-right text-xs text-muted-foreground">{blockReason}</p>
|
|
)}
|
|
|
|
<DialogFooter className="mt-6">
|
|
<Button type="button" variant="ghost" onClick={close}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={!canSubmit}>
|
|
{create.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Create wallet
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{pendingSeed && (
|
|
<SeedBackupDialog
|
|
open
|
|
mnemonic={pendingSeed.mnemonic}
|
|
walletName={pendingSeed.walletName}
|
|
onConfirmed={seedConfirmed}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const SegButton = ({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) => (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
|
active ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|