This commit is contained in:
2026-10-19 15:42:39 -04:00
parent 6d3f470922
commit 4b47386853
218 changed files with 25348 additions and 383 deletions
+125
View File
@@ -0,0 +1,125 @@
import { useId, useRef, useState, type FormEvent } from 'react';
import { exportWallet, restoreWallet, type Wallet } from './wallet.ts';
type Props = {
wallet: Wallet | undefined;
onRestore: (wallet: Wallet) => void;
onNotice: (message: string) => void;
};
export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
const id = useId();
const [password, setPassword] = useState('');
const [confirmation, setConfirmation] = useState('');
const [importPassword, setImportPassword] = useState('');
const [file, setFile] = useState<File>();
const [replace, setReplace] = useState(false);
const [busy, setBusy] = useState(false);
const inProgress = useRef(false);
const fileInput = useRef<HTMLInputElement>(null);
async function download(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (inProgress.current || !wallet) return;
if (password.length < 12 || password.length > 1024 || password !== confirmation) {
onNotice('Use a password of 12–1024 characters and enter it identically twice.');
return;
}
inProgress.current = true;
setBusy(true);
try {
const backup = await exportWallet(wallet, password);
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = `wikideck-testnet-wallet-${wallet.address}.json`;
document.body.append(link);
try { link.click(); }
finally {
link.remove();
// Let the browser start consuming the download before revocation.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
onNotice('Encrypted backup download requested. Keep the file and password safe; test recovery before relying on it.');
} catch { onNotice('Could not export the wallet. Check browser support and try again.'); }
finally {
setPassword('');
setConfirmation('');
inProgress.current = false;
setBusy(false);
}
}
async function recover(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (inProgress.current) return;
if (!file || file.size === 0 || file.size > 32 * 1024) {
onNotice('Choose a nonempty wallet backup JSON file no larger than 32 KB.');
return;
}
if (wallet && !replace) {
onNotice('Confirm replacement of the active wallet first. Back it up before continuing.');
return;
}
inProgress.current = true;
setBusy(true);
try {
// Size checked before reading; the shared decoder validates all JSON fields.
const backup: unknown = JSON.parse(await file.text());
const recovered = await restoreWallet(backup, importPassword);
onRestore(recovered);
onNotice('Wallet recovered and saved locally. Other open tabs should reload before signing.');
setFile(undefined);
setReplace(false);
if (fileInput.current) fileInput.current.value = '';
} catch {
onNotice('Recovery failed. Check the password, backup format and network, and browser storage. The backup must be intact.');
} finally {
setImportPassword('');
inProgress.current = false;
setBusy(false);
}
}
return <section aria-labelledby={`${id}-title`} aria-busy={busy}>
<h2 id={`${id}-title`}>Wallet backup &amp; recovery</h2>
<p>This is a recoverable P-256 wallet for wikideck-randomx-testnet-2, separate from your old demo wallet.</p>
<p>The exportable private key is stored locally in this browser’s IndexedDB, not encrypted by this password.
Scripts running on this origin or someone with access to your browser profile may access it.
Clearing site data without a backup loses access.</p>
<p>Backup encryption and recovery happen locally. No private key, backup file, or password is transmitted to a server
by this panel. Public addresses, public keys and signatures may be shared during normal wallet use.</p>
<p><strong>Forgotten backup passwords cannot be reset or recovered.</strong> Keep the encrypted file and a strong,
unique password safe and separate. Anyone with both can control this wallet. Downloading does not guarantee the file was saved.</p>
<form onSubmit={download}>
<fieldset disabled={busy || !wallet}>
<legend>Download encrypted backup</legend>
<label htmlFor={`${id}-password`}>Backup password (at least 12 characters)</label>
<input id={`${id}-password`} type="password" autoComplete="new-password" minLength={12} maxLength={1024}
required value={password} onChange={event => setPassword(event.target.value)} />
<label htmlFor={`${id}-confirm`}>Confirm backup password</label>
<input id={`${id}-confirm`} type="password" autoComplete="new-password" minLength={12} maxLength={1024}
required value={confirmation} onChange={event => setConfirmation(event.target.value)} />
<button type="submit">Download encrypted backup</button>
</fieldset>
</form>
<form onSubmit={recover}>
<fieldset disabled={busy}>
<legend>Recover from a local backup</legend>
<label htmlFor={`${id}-file`}>Backup JSON file (maximum 32 KB)</label>
<input ref={fileInput} id={`${id}-file`} type="file" accept=".json,application/json" required
onChange={event => { setFile(event.target.files?.[0]); setReplace(false); }} />
<label htmlFor={`${id}-import-password`}>Backup password</label>
<input id={`${id}-import-password`} type="password" autoComplete="off" required maxLength={1024}
value={importPassword} onChange={event => setImportPassword(event.target.value)} />
{wallet && <label>
<input type="checkbox" required checked={replace} onChange={event => setReplace(event.target.checked)} />
I have backed up my active wallet and confirm replacing it. Recovery does not transfer its assets.
</label>}
<button type="submit" disabled={!file || (!!wallet && !replace)}>Recover and save wallet</button>
</fieldset>
</form>
</section>;
}
export default WalletPanel;