Fix web UI messaging, alias resolution, and onboarding

Messaging:
- Append \n to WS IPC sends so bufio.Scanner fires (messages from web
  were silently dropped — the line scanner never saw a newline)
- Handle session_ready in web store to update peer aliases after hello

Alias resolution:
- Seed AddPeer alias from SQLite cache so returning peers resolve
  immediately, before hello exchange completes
- Remove stale SavePeer call from AddPeer (was writing placeholder IDs)
- Add PeerAlias() point-lookup to store

Onboarding redesign:
- Three-state UI: disconnected (daemon instructions), connecting,
  connected-no-network (join form)
- Read ?n= / ?network= / ?anchor= / ?invite=waste:... URL params to
  pre-fill the join form for invite links
- Show daemon alias + peer ID before any network is joined
  (master_alias + master_id now included in state_snapshot)
- Identity backup: export (passphrase-protected yaw-key-backup-1 JSON,
  download button) and restore (paste + passphrase verify)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-22 23:22:32 +02:00
parent 2bc1dbbedf
commit 7fe02e9463
10 changed files with 265 additions and 34 deletions

View File

@@ -1,35 +1,206 @@
import { useState, useEffect } from 'react'
import { useWaste } from '../store'
interface Props {
status: 'disconnected' | 'connecting' | 'connected'
}
// Parse URL search params for pre-filling the join form.
// Supports:
// ?invite=waste:<b64> waste: invite string (anchor + network name)
// ?n=<name> network name shorthand
// ?network=<name> network name
// ?a=<url> anchor URL hint (informational — daemon controls its anchor)
function parseInviteParams(): { network: string; anchor: string; inviteString: string } {
const p = new URLSearchParams(window.location.search)
const inviteString = p.get('invite') ?? ''
let network = p.get('n') ?? p.get('network') ?? ''
let anchor = p.get('a') ?? p.get('anchor') ?? ''
if (inviteString.startsWith('waste:')) {
try {
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
network = network || json.network || ''
anchor = anchor || json.anchor || ''
} catch { /* ignore bad invite */ }
}
return { network, anchor, inviteString }
}
export function Onboarding({ status }: Props) {
const { send } = useWaste()
const { send, masterAlias, masterId, exportedBackup } = useWaste()
const [network, setNetwork] = useState('')
const [inviteString, setInviteString] = useState('')
const [anchorHint, setAnchorHint] = useState('')
const [exportPass, setExportPass] = useState('')
const [importJson, setImportJson] = useState('')
const [importPass, setImportPass] = useState('')
const [importStatus, setImportStatus] = useState('')
const [showBackup, setShowBackup] = useState(false)
const label = status === 'connecting'
? 'Connecting to daemon…'
: 'Waiting for daemon — is waste-daemon running?'
useEffect(() => {
const { network: n, anchor: a, inviteString: inv } = parseInviteParams()
if (n) setNetwork(n)
if (a) setAnchorHint(a)
if (inv) setInviteString(inv)
}, [])
function joinNetwork(e: React.FormEvent<HTMLFormElement>) {
function joinNetwork(e: React.FormEvent) {
e.preventDefault()
const form = e.currentTarget
const name = (form.elements.namedItem('network') as HTMLInputElement).value.trim()
const name = network.trim()
if (!name) return
send({ type: 'join_network', network_name: name })
}
function exportIdentity(e: React.FormEvent) {
e.preventDefault()
if (!exportPass.trim()) return
setExportBlob('')
send({ type: 'export_identity', passphrase: exportPass })
}
function importIdentity(e: React.FormEvent) {
e.preventDefault()
if (!importJson.trim() || !importPass.trim()) return
setImportStatus('Verifying…')
send({ type: 'import_identity', backup: importJson, passphrase: importPass })
}
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
// ── disconnected / connecting ────────────────────────────────────────────────
if (status !== 'connected') {
return (
<div className="onboarding">
<h1>waste</h1>
{status === 'connecting' ? (
<p className="status connecting">Connecting to daemon</p>
) : (
<>
<p className="status disconnected">Daemon not running</p>
<p className="onboarding-hint">
Start the daemon to use the web UI:
</p>
<pre className="onboarding-code">./launch-web.sh</pre>
<p className="onboarding-hint muted">
Or pass a custom alias and network:
</p>
<pre className="onboarding-code">ALIAS=alice NETWORK=friends ./launch-web.sh</pre>
</>
)}
</div>
)
}
// ── connected — join form ────────────────────────────────────────────────────
return (
<div className="onboarding">
<h1>waste</h1>
<p className="status">{label}</p>
{status === 'connected' && (
<form onSubmit={joinNetwork} className="join-form">
<input name="network" placeholder="network name" autoComplete="off" autoFocus />
<button type="submit">Join</button>
</form>
{masterAlias && (
<div className="onboarding-identity">
<span className="alias">{masterAlias}</span>
{shortId && <span className="peer-id mono">{shortId}</span>}
</div>
)}
<form onSubmit={joinNetwork} className="join-form">
<label className="join-label">Join a network</label>
{anchorHint && (
<p className="onboarding-hint muted">via {anchorHint}</p>
)}
<input
value={network}
onChange={e => setNetwork(e.target.value)}
placeholder="network name"
autoComplete="off"
autoFocus
/>
{inviteString && (
<input
value={inviteString}
readOnly
className="muted mono"
style={{ fontSize: '0.72rem' }}
title="invite string"
/>
)}
<button type="submit" className="primary" disabled={!network.trim()}>
Join
</button>
</form>
<div className="onboarding-section">
<button
className="toggle-link"
onClick={() => setShowBackup(b => !b)}
>
{showBackup ? '▾' : '▸'} Identity backup
</button>
{showBackup && (
<div className="backup-panel">
<p className="onboarding-hint">
Export your identity as an encrypted file. You can import it on
any machine or in the TUI with <code>--import-identity</code>.
</p>
<form onSubmit={exportIdentity} className="backup-form">
<input
type="password"
value={exportPass}
onChange={e => setExportPass(e.target.value)}
placeholder="passphrase for backup"
autoComplete="new-password"
/>
<button type="submit" disabled={!exportPass.trim()}>Export</button>
</form>
{exportedBackup && (
<div className="export-result">
<textarea
readOnly
value={exportedBackup}
rows={4}
className="mono"
onClick={e => (e.target as HTMLTextAreaElement).select()}
/>
<button onClick={() => {
const a = document.createElement('a')
a.href = URL.createObjectURL(new Blob([exportedBackup], { type: 'application/json' }))
a.download = `waste-identity-${masterId?.slice(0, 8) ?? 'backup'}.json`
a.click()
}}>Download file</button>
</div>
)}
<details style={{ marginTop: '1rem' }}>
<summary className="onboarding-hint" style={{ cursor: 'pointer' }}>Restore from backup</summary>
<form onSubmit={importIdentity} className="backup-form" style={{ marginTop: '0.5rem' }}>
<textarea
value={importJson}
onChange={e => setImportJson(e.target.value)}
placeholder='{"yaw":"yaw-key-backup-1", …}'
rows={3}
className="mono"
/>
<input
type="password"
value={importPass}
onChange={e => setImportPass(e.target.value)}
placeholder="passphrase"
autoComplete="current-password"
/>
<button type="submit" disabled={!importJson.trim() || !importPass.trim()}>
Verify & restore
</button>
{importStatus && <p className="onboarding-hint">{importStatus}</p>}
</form>
</details>
</div>
)}
</div>
</div>
)
}