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, { 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('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(24); const [importWords, setImportWords] = useState(() => 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>({}); // 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].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 ( <> (next ? onOpenChange(true) : close())}> {/* Wider only while importing — 24 word boxes at max-w-lg are two cramped columns. */} { if (hasTypedSecret) ev.preventDefault(); }} onEscapeKeyDown={(ev) => { if (hasTypedSecret) ev.preventDefault(); }} >
Add a wallet Self-custodial keys stay in the wallet sidecar; a remote node is only a stored connection. {storeKeyMissing && (
The secret store is unreachable. The sidecar refuses to store wallet secrets without it, so creation is disabled until it is set.
)}
setName(ev.target.value)} />
{config && (

Network: {config.network}

)}
{isOnchain ? ( <>
setImporting(false)} label="Generate a new seed" /> setImporting(true)} label="Import a phrase" />
{importing ? (
toggleTextMode(!asText)} label={asText ? 'Use word boxes' : 'Paste as text'} />
{!asText && (
{SEED_LENGTHS.map((n) => ( resizeImport(n)} label={`${n}`} /> ))}
)} {/* Secret in flight: state → request body → cleared. Never stored on this side, and never echoed back by the sidecar either. */} {asText ? (