diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/auth-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/auth-page.js | 268 |
1 files changed, 260 insertions, 8 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index 860f890..7c92d52 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -2,7 +2,9 @@ import { html, useState, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; -import { hubFetch, navigate } from './hub-client.js'; +import { + hubFetch, navigate, session, HUB, loadAuth, _storeRecoveryKey, +} from './hub-client.js'; import * as platform from './platform.js'; const PASSWORD_MIN_BITS = 60; @@ -72,12 +74,15 @@ export function LoginPage({ onLogin }) { const onSubmit = async (e) => { e.preventDefault(); - if (!username || !password) return; + const name = username.trim(); + if (!name || !password) return; setError(''); setPendingVerif(false); setLoading(true); try { - await onLogin(username, password); + // Trimmed to match the hub's stored username and every client-side key + // derivation (auth_key, bundle_key, recovery_key all fold the username in). + await onLogin(name, password); navigate('/'); } catch (err) { if (err.message === 'email_verification_required') { @@ -114,6 +119,9 @@ export function LoginPage({ onLogin }) { <div class="login-footer"> ${t('login.no_account')} <a href="#/register">${t('login.register_link')}</a> </div> + <div class="login-footer"> + <a href="#/reset">${t('login.forgot')}</a> + </div> </div> </div> `; @@ -125,14 +133,19 @@ export function RegisterPage() { const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [error, setError] = useState(''); - const [phase, setPhase] = useState('form'); // form | verify | done + const [phase, setPhase] = useState('form'); // form | recovery | verify | done const [loading, setLoading] = useState(false); const [code, setCode] = useState(''); const [verifying, setVerifying] = useState(false); const [resent, setResent] = useState(false); + const [recoveryMnemonic, setRecoveryMnemonic] = useState(''); + const [recoverySaved, setRecoverySaved] = useState(false); + const [recoveryCopied, setRecoveryCopied] = useState(false); + const [emailRecovery, setEmailRecovery] = useState(true); const onSubmit = async (e) => { e.preventDefault(); + const name = username.trim(); if (password !== confirm) { setError(t('register.err_mismatch')); return; } if (password.length < PASSWORD_MIN_LEN) { setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return; @@ -144,14 +157,30 @@ export function RegisterPage() { setLoading(true); try { if (window.MeshBayKeys) { - await window.MeshBayKeys.registerUser(username, email, password); + // The account recovery key (docs/auth-confirm.md §4.3/§4.4): generated + // here, shown once on the next screen. When the user leaves "e-mail it" + // checked, the mnemonic goes in the register body so the hub appends it + // to the verification e-mail (and stores it nowhere); otherwise it is + // screen-only. The derived key is kept in `session.recoveryKey` and + // persisted, so groups joined later — this session or a future one — + // still leave a recovery-wrapped copy on their node. + const rk = window.MeshBayKeys.generateRecoveryKey(); + // `name` (trimmed), not the raw field: the hub stores the trimmed + // username and every key derivation must fold in the same string. + await window.MeshBayKeys.registerUser( + name, email, password, emailRecovery ? rk.mnemonic : null); + setRecoveryMnemonic(rk.mnemonic); + session.recoveryKey = + await window.MeshBayKeys.deriveRecoveryKey(rk.mnemonic, name); + await _storeRecoveryKey(session.recoveryKey); + setPhase('recovery'); } else { await hubFetch('/v1/users/register', { method: 'POST', - body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, + body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, }); + setPhase('verify'); } - setPhase('verify'); } catch (err) { setError(err.message); } finally { @@ -183,7 +212,10 @@ export function RegisterPage() { try { await hubFetch('/v1/users/register', { method: 'POST', - body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, + body: { + username: username.trim(), email, password, + pk_user_ed25519: '', pk_user_x25519: '', + }, }); setResent(true); } catch (err) { @@ -205,6 +237,48 @@ export function RegisterPage() { `; } + if (phase === 'recovery') { + const copyRecovery = async () => { + try { + await navigator.clipboard.writeText(recoveryMnemonic); + setRecoveryCopied(true); + setTimeout(() => setRecoveryCopied(false), 2000); + } catch { /* clipboard blocked — the text is on screen to copy by hand */ } + }; + return html` + <div class="page-center"> + <div class="card login-card"> + <h2>${t('register.recovery_title')}</h2> + <p style="margin-bottom:12px; color:var(--text-secondary)"> + ${t('register.recovery_intro')} + </p> + <code style="display:block; padding:12px; border:1px solid var(--border); + border-radius:6px; font-size:1.05em; letter-spacing:0.12em; + line-height:1.9; word-spacing:0.3em; text-align:center; + user-select:all; background:var(--bg-secondary, transparent)"> + ${recoveryMnemonic} + </code> + <button class="btn-secondary" style="margin-top:8px" onClick=${copyRecovery}> + ${recoveryCopied ? t('register.recovery_copied') : t('register.recovery_copy')} + </button> + <p style="margin-top:12px; color:var(--text-secondary)"> + ${emailRecovery ? t('register.recovery_emailed') : t('register.recovery_not_emailed')} + </p> + <p class="error-msg" style="margin-top:8px">${t('register.recovery_warning')}</p> + <label style="display:flex; gap:8px; align-items:flex-start; margin-top:12px"> + <input type="checkbox" checked=${recoverySaved} + onChange=${e => setRecoverySaved(e.target.checked)} /> + <span>${t('register.recovery_saved')}</span> + </label> + <button style="margin-top:12px" disabled=${!recoverySaved} + onClick=${() => setPhase('verify')}> + ${t('register.recovery_continue')} + </button> + </div> + </div> + `; + } + if (phase === 'verify') { return html` <div class="page-center"> @@ -263,6 +337,12 @@ export function RegisterPage() { <input type="password" placeholder="${t('register.confirm')}" value=${confirm} onInput=${e => setConfirm(e.target.value)} autocomplete="new-password" required /> + <label style="display:flex; gap:8px; align-items:flex-start; margin:4px 0 2px; + font-size:0.88em; color:var(--text-secondary)"> + <input type="checkbox" checked=${emailRecovery} + onChange=${e => setEmailRecovery(e.target.checked)} /> + <span>${t('register.recovery_email_opt')}</span> + </label> ${error && html`<div class="error-msg">${error}</div>`} <button type="submit" disabled=${loading}> ${loading ? t('register.loading') : t('register.submit')} @@ -275,3 +355,175 @@ export function RegisterPage() { </div> `; } + + +// ── Passphrase reset — Flow B (docs/auth-confirm.md §4) ───────────────────── +// +// An e-mail code restores hub login. A recovery key, if the user still has one, +// restores the per-node identities in the same step: the fan-out reads each +// node's recovery-wrapped bundle and re-wraps it under the new passphrase. +// Without a recovery key, sign-in comes back and the groups do not. +export function ResetPasswordPage({ onLogin }) { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [phase, setPhase] = useState('request'); // request | form | working | done | norecovery + const [code, setCode] = useState(''); + const [recovery, setRecovery] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState(null); + const [result, setResult] = useState(null); + + const requestCode = async (e) => { + e.preventDefault(); + if (!username.trim() || !email.trim()) return; + setError(''); + setBusy(true); + try { + await hubFetch('/v1/users/password/reset-request', { + method: 'POST', + body: { username: username.trim(), email: email.trim() }, + }); + setPhase('form'); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const doReset = async (e) => { + e.preventDefault(); + if (!window.MeshBayKeys) { setError(t('reset.err_unsupported')); return; } + if (password.length < PASSWORD_MIN_LEN) { + setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return; + } + if (passwordBits(password) < PASSWORD_MIN_BITS) { + setError(t('register.err_too_weak')); return; + } + if (password !== confirm) { setError(t('register.err_mismatch')); return; } + setError(''); + setBusy(true); + let signedIn = false; + try { + const name = username.trim(); + const newAuthKey = await window.MeshBayKeys.deriveAuthKey(password, name); + // Before this point a failure means the code or passphrase is wrong and + // the account is untouched — go back to the form. + await hubFetch('/v1/users/password/reset', { + method: 'POST', + body: { username: name, code: code.trim(), new_auth_key: newAuthKey }, + }); + await onLogin(name, password); // sets session.bundleKey + signedIn = true; + + const mnemonic = recovery.trim(); + if (!mnemonic) { setPhase('norecovery'); return; } + + session.recoveryKey = + await window.MeshBayKeys.deriveRecoveryKey(mnemonic, name); + await _storeRecoveryKey(session.recoveryKey); + setPhase('working'); + const auth = loadAuth() || {}; + const r = await window.MeshBayTransport.rewrapAllNodes({ + hubUrl: HUB, token: auth.token, username: name, userId: auth.userId, + newPassphrase: password, recoveryKey: mnemonic, + onProgress: setProgress, + }); + setResult(r); + setPhase('done'); + } catch (err) { + setError(err.message); + // After sign-in the reset already happened and the code is spent — do not + // send the user back to re-enter it. Land on the done screen with the + // error shown; their groups may need the operator fallback. + setPhase(signedIn ? 'done' : 'form'); + } finally { + setBusy(false); + } + }; + + if (phase === 'working') { + return html` + <div class="page-center"><div class="card login-card"> + <h2>${t('reset.title')}</h2> + <p class="settings-hint"> + ${t('reset.working')} + ${progress && progress.total ? ` (${progress.done}/${progress.total})` : ''} + </p> + </div></div> + `; + } + + if (phase === 'done' || phase === 'norecovery') { + const stragglers = phase === 'done' && result + ? result.unreachable.concat(result.failed) : []; + return html` + <div class="page-center"><div class="card login-card"> + <h2>${t('reset.title')}</h2> + <p style="color:var(--success)">${t('reset.signin_restored')}</p> + ${error && html`<div class="error-msg" style="margin-top:8px">${error}</div>`} + ${phase === 'norecovery' && html` + <p class="settings-hint" style="margin-top:8px">${t('reset.no_recovery')}</p>`} + ${phase === 'done' && !error && stragglers.length === 0 && html` + <p class="settings-hint" style="margin-top:8px">${t('reset.groups_restored')}</p>`} + ${stragglers.length > 0 && html` + <p class="settings-hint" style="margin-top:8px">${t('reset.needs_operator')}</p> + <ul style="margin:0 0 8px 18px"> + ${stragglers.map(g => html`<li>${g.name}${g.reason ? ` — ${g.reason}` : ''}</li>`)} + </ul>`} + <button style="margin-top:12px" onClick=${() => navigate('/')}> + ${t('reset.go_app')} + </button> + </div></div> + `; + } + + return html` + <div class="page-center"><div class="card login-card"> + <h2>${t('reset.title')}</h2> + ${phase === 'request' && html` + <p style="margin-bottom:12px; color:var(--text-secondary)">${t('reset.request_intro')}</p> + <form onSubmit=${requestCode}> + <input type="text" placeholder="${t('login.username')}" value=${username} + onInput=${e => setUsername(e.target.value)} + autocomplete="username" required autofocus /> + <input type="email" placeholder="${t('register.email')}" value=${email} + onInput=${e => setEmail(e.target.value)} + autocomplete="email" required /> + ${error && html`<div class="error-msg">${error}</div>`} + <button type="submit" disabled=${busy}>${t('reset.send_code')}</button> + </form>`} + + ${phase === 'form' && html` + <p style="margin-bottom:12px; color:var(--text-secondary)">${t('reset.form_intro')}</p> + <form onSubmit=${doReset}> + <input type="text" placeholder="${t('reset.code')}" value=${code} + onInput=${e => setCode(e.target.value)} + inputmode="numeric" maxlength="6" required autofocus + style="text-align:center;font-size:1.3em;letter-spacing:0.3em" /> + <textarea placeholder="${t('reset.recovery_key')}" value=${recovery} + onInput=${e => setRecovery(e.target.value)} rows="2" + style="width:100%;font-family:monospace;font-size:0.9em; + letter-spacing:0.08em;resize:vertical"></textarea> + <p style="font-size:0.8em;color:var(--text-dim);margin:-4px 0 8px"> + ${t('reset.recovery_key_hint')} + </p> + <input type="password" placeholder="${t('reset.new_pass')}" value=${password} + onInput=${e => setPassword(e.target.value)} + autocomplete="new-password" required /> + <input type="password" placeholder="${t('reset.new_pass_repeat')}" value=${confirm} + onInput=${e => setConfirm(e.target.value)} + autocomplete="new-password" required /> + ${error && html`<div class="error-msg">${error}</div>`} + <button type="submit" disabled=${busy}>${t('reset.submit')}</button> + </form>`} + + <div class="login-footer"> + <a href="#/login">${t('reset.back_to_login')}</a> + </div> + </div></div> + `; +} |