Files
wikideck/src/WalletPanel.tsx
T
2026-10-20 05:27:18 -04:00

168 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useId, useRef, useState, type FormEvent } from 'react';
import { exportWallet, restoreWallet, protectWallet, unlockWallet, lockWallet, walletStatus, subscribeWallet,
type WalletStatus, type Wallet } from './wallet.ts';
import { NETWORK } from '../shared/network.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 [status,setStatus] = useState<WalletStatus>();
const [localPassword,setLocalPassword] = useState('');
const [localConfirmation,setLocalConfirmation] = useState('');
const [error,setError] = useState('');
const inProgress = useRef(false);
const fileInput = useRef<HTMLInputElement>(null);
useEffect(()=>{
let active=true;
const load=()=>{void walletStatus().then(s=>{if(active)setStatus(s);}).catch(()=>{if(active)setError('Wallet storage unavailable.');});};
load();const unsubscribe=subscribeWallet(()=>{
setPassword('');setConfirmation('');setImportPassword('');setLocalPassword('');setLocalConfirmation('');load();
});
const clear=()=>{if(document.visibilityState!=='visible'){setPassword('');setConfirmation('');setImportPassword('');setLocalPassword('');setLocalConfirmation('');}};
document.addEventListener('visibilitychange',clear);
return()=>{active=false;unsubscribe();document.removeEventListener('visibilitychange',clear);};
},[]);
async function open(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); if(inProgress.current||!status)return;
if(status!=='locked'&&(localPassword.length<12||localPassword!==localConfirmation)) {setError('Choose at least 12 characters and confirm the local password.');return;}
inProgress.current=true;setBusy(true);setError('');
try {
const opened= status==='locked'?await unlockWallet(localPassword):await protectWallet(localPassword);
onRestore(opened);onNotice('Wallet unlocked in this tab. Locking or leaving the tab clears its signing session.');
} catch(e) {setError(e instanceof Error?e.message:'Could not unlock wallet.');}
finally {setLocalPassword('');setLocalConfirmation('');inProgress.current=false;setBusy(false);setStatus(await walletStatus());}
}
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 = `${NETWORK}-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 (status !== 'empty' && !replace) {
onNotice('Confirm replacement of the active wallet first. Back it up before continuing.');
return;
}
inProgress.current = true;
setBusy(true);
try {
if(localPassword.length<12||localPassword!==localConfirmation)throw new Error('Choose and confirm a local encryption password of at least 12 characters.');
// 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, localPassword, replace);
onRestore(recovered);
onNotice('Wallet recovered into encrypted local storage. Other tabs are locked; keep the backup safe.');
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('');
setLocalPassword('');setLocalConfirmation('');
inProgress.current = false;
setBusy(false);
}
}
return <section aria-labelledby={`${id}-title`} aria-busy={busy}>
<h2 id={`${id}-title`}>Wallet backup &amp; recovery</h2>
<p>Local P-256 wallet for {NETWORK}. Status: <strong>{status??'checking storage'}</strong>.</p>
<p>New and migrated keys are encrypted in IndexedDB. Unlocking keeps an exportable signing key only in this tab’s memory.
Lock manually, after five idle minutes, after four hours, or automatically when the tab is hidden.
Encryption does not protect an unlocked page from malicious same-origin scripts or extensions. Clearing site data without a backup loses access.</p>
{status==='legacy'&&<p role="alert"><strong>Existing unencrypted wallet detected.</strong> Encrypt the same identity below; migration never generates a replacement key. The original record is replaced only after encrypted storage commits. Existing disk copies/backups may still contain the old plaintext key.</p>}
{error&&<p role="alert" className="transfer-error">{error}</p>}
{wallet?<button type="button" onClick={()=>{lockWallet();setPassword('');setConfirmation('');setImportPassword('');setLocalPassword('');setLocalConfirmation('');}} disabled={busy}>Lock wallet now</button>:<form onSubmit={open}><fieldset disabled={busy||!status}>
<legend>{status==='locked'?'Unlock encrypted local wallet':status==='legacy'?'Encrypt existing wallet':'Create encrypted wallet'}</legend>
<label htmlFor={`${id}-local-password`}>Local wallet password {status!=='locked'?'(at least 12 characters)':''}</label>
<input id={`${id}-local-password`} type="password" autoComplete={status==='locked'?'current-password':'new-password'} required maxLength={1024} minLength={status==='locked'?1:12} value={localPassword} onChange={e=>setLocalPassword(e.target.value)}/>
{status!=='locked'&&<><label htmlFor={`${id}-local-confirm`}>Confirm local password</label><input id={`${id}-local-confirm`} type="password" autoComplete="new-password" required minLength={12} maxLength={1024} value={localConfirmation} onChange={e=>setLocalConfirmation(e.target.value)}/></>}
<button type="submit">{status==='locked'?'Unlock wallet':status==='legacy'?'Encrypt existing wallet':'Create encrypted wallet'}</button>
</fieldset></form>}
<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)} />
<label htmlFor={`${id}-recover-local`}>New local encryption password (at least 12 characters)</label>
<input id={`${id}-recover-local`} type="password" autoComplete="new-password" minLength={12} maxLength={1024} required value={localPassword} onChange={e=>setLocalPassword(e.target.value)}/>
<label htmlFor={`${id}-recover-confirm`}>Confirm new local password</label>
<input id={`${id}-recover-confirm`} type="password" autoComplete="new-password" minLength={12} maxLength={1024} required value={localConfirmation} onChange={e=>setLocalConfirmation(e.target.value)}/>
{status!=='empty' && <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={!status||!file || (status!=='empty' && !replace)}>Recover and save encrypted wallet</button>
</fieldset>
</form>
</section>;
}
export default WalletPanel;