import { html, useState, useEffect, useRef, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { hubFetch, navigate, session, HUB, loadAuth, _storeRecoveryKey, } from './hub-client.js'; import * as platform from './platform.js'; const PASSWORD_MIN_BITS = 60; const PASSWORD_MIN_LEN = 12; // ── reCAPTCHA v2 helper ────────────────────────────────────────────────────── let _captchaSiteKey = null; let _captchaKeyFetched = false; async function fetchCaptchaSiteKey() { if (_captchaKeyFetched) return _captchaSiteKey; try { const info = await hubFetch('/v1/hub/info'); _captchaSiteKey = info.captcha_site_key || null; } catch { _captchaSiteKey = null; } _captchaKeyFetched = true; return _captchaSiteKey; } function loadRecaptchaScript() { if (document.getElementById('recaptcha-script')) return; const s = document.createElement('script'); s.id = 'recaptcha-script'; s.src = 'https://www.google.com/recaptcha/api.js?render=explicit'; s.async = true; s.defer = true; document.head.appendChild(s); } function useCaptcha() { const [siteKey, setSiteKey] = useState(_captchaSiteKey); const [token, setToken] = useState(null); const containerRef = useRef(null); const widgetId = useRef(null); useEffect(() => { fetchCaptchaSiteKey().then(k => { if (k) { setSiteKey(k); loadRecaptchaScript(); } }); }, []); useEffect(() => { if (!siteKey || !containerRef.current) return; const poll = setInterval(() => { if (window.grecaptcha && window.grecaptcha.render && widgetId.current === null) { clearInterval(poll); widgetId.current = window.grecaptcha.render(containerRef.current, { sitekey: siteKey, callback: (tk) => setToken(tk), 'expired-callback': () => setToken(null), theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light', }); } }, 100); return () => clearInterval(poll); }, [siteKey]); const reset = useCallback(() => { if (widgetId.current !== null && window.grecaptcha) { window.grecaptcha.reset(widgetId.current); setToken(null); } }, []); const widget = siteKey ? html`
` : null; return { token, widget, reset, enabled: !!siteKey }; } function passwordBits(pw) { if (!pw) return 0; let pool = 0; if (/[a-z]/.test(pw)) pool += 26; if (/[A-Z]/.test(pw)) pool += 26; if (/[0-9]/.test(pw)) pool += 10; if (/[^A-Za-z0-9]/.test(pw)) pool += 32; let bits = pw.length * Math.log2(pool || 1); const unique = new Set(pw).size; if (unique < pw.length / 2) bits *= 0.6; if (/^[0-9]+$/.test(pw)) bits *= 0.5; if (/(password|motdepasse|azerty|qwerty|123456|meshbay)/i.test(pw)) bits *= 0.3; return Math.round(bits); } export function FirstRunPage({ onSet }) { const [url, setUrl] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const submit = async (e) => { e.preventDefault(); setError(''); setBusy(true); try { await window.meshbay.setHubBase(url.trim()); onSet(); } catch (err) { setError(platform.bridgeMessage(err)); setBusy(false); } }; return html`

${t('firstrun.title')}

${t('firstrun.hint')}

setUrl(e.target.value)} />
${error && html`
${error}
`}

${t('firstrun.note')}

`; } export function LoginPage({ onLogin }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [pendingVerif, setPendingVerif] = useState(false); const onSubmit = async (e) => { e.preventDefault(); const name = username.trim(); if (!name || !password) return; setError(''); setPendingVerif(false); setLoading(true); try { // 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') { setPendingVerif(true); } else { setError(err.message); } } finally { setLoading(false); } }; return html`

${t('login.title')}

setUsername(e.target.value)} autocomplete="username" required autofocus /> setPassword(e.target.value)} autocomplete="current-password" required /> ${error && html`
${error}
`} ${pendingVerif && html`

${t('login.pending_verification')}

`}
`; } export function RegisterPage() { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [error, setError] = useState(''); 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 captcha = useCaptcha(); 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; } if (passwordBits(password) < PASSWORD_MIN_BITS) { setError(t('register.err_too_weak')); return; } setError(''); setLoading(true); try { if (window.MeshBayKeys) { // 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. // `captcha.token` rides along — the submit button is already disabled // until it is set when a captcha is configured (see the form below). await window.MeshBayKeys.registerUser( name, email, password, emailRecovery ? rk.mnemonic : null, captcha.token); 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: name, email, password, pk_user_ed25519: '', pk_user_x25519: '', captcha_token: captcha.token, }, }); setPhase('verify'); } } catch (err) { setError(err.message); // A reCAPTCHA token is single-use: after a failed attempt (name taken, // e-mail in use…) it is spent, so clear it and make the user solve a // fresh one before the next try. No-op when no captcha is configured. captcha.reset(); } finally { setLoading(false); } }; const onVerify = async (e) => { e.preventDefault(); if (!code.trim()) return; setError(''); setVerifying(true); try { await hubFetch('/v1/users/verify-email', { method: 'POST', body: { email, code: code.trim() }, }); setPhase('done'); } catch (err) { setError(err.message); } finally { setVerifying(false); } }; const onResend = async () => { setError(''); setResent(false); try { await hubFetch('/v1/users/register', { method: 'POST', body: { username: username.trim(), email, password, pk_user_ed25519: '', pk_user_x25519: '', }, }); setResent(true); } catch (err) { setError(err.message); } }; if (phase === 'done') { return html`

${t('register.verified_title')}

${t('register.verified_msg')}

${t('register.go_login')}
`; } 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`

${t('register.recovery_title')}

${t('register.recovery_intro')}

${recoveryMnemonic}

${emailRecovery ? t('register.recovery_emailed') : t('register.recovery_not_emailed')}

${t('register.recovery_warning')}

`; } if (phase === 'verify') { return html`

${t('register.success_title')}

${t('register.success_msg')}

setCode(e.target.value)} autocomplete="one-time-code" inputmode="numeric" maxlength="6" required autofocus style="text-align:center;font-size:1.4em;letter-spacing:0.3em" /> ${error && html`
${error}
`}
`; } return html`

${t('register.title')}

setUsername(e.target.value)} autocomplete="username" required /> setEmail(e.target.value)} autocomplete="email" required /> setPassword(e.target.value)} autocomplete="new-password" required minlength="8" />

${t('register.strength', { bits: passwordBits(password) })}

setConfirm(e.target.value)} autocomplete="new-password" required /> ${captcha.widget} ${error && html`
${error}
`}
`; } // ── 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 captcha = useCaptcha(); 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(), captcha_token: captcha.token, }, }); 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`

${t('reset.title')}

${t('reset.working')} ${progress && progress.total ? ` (${progress.done}/${progress.total})` : ''}

`; } if (phase === 'done' || phase === 'norecovery') { const stragglers = phase === 'done' && result ? result.unreachable.concat(result.failed) : []; return html`

${t('reset.title')}

${t('reset.signin_restored')}

${error && html`
${error}
`} ${phase === 'norecovery' && html`

${t('reset.no_recovery')}

`} ${phase === 'done' && !error && stragglers.length === 0 && html`

${t('reset.groups_restored')}

`} ${stragglers.length > 0 && html`

${t('reset.needs_operator')}

`}
`; } return html`

${t('reset.title')}

${phase === 'request' && html`

${t('reset.request_intro')}

setUsername(e.target.value)} autocomplete="username" required autofocus /> setEmail(e.target.value)} autocomplete="email" required /> ${captcha.widget} ${error && html`
${error}
`}
`} ${phase === 'form' && html`

${t('reset.form_intro')}

setCode(e.target.value)} inputmode="numeric" maxlength="6" required autofocus style="text-align:center;font-size:1.3em;letter-spacing:0.3em" />

${t('reset.recovery_key_hint')}

setPassword(e.target.value)} autocomplete="new-password" required /> setConfirm(e.target.value)} autocomplete="new-password" required /> ${error && html`
${error}
`}
`}
`; }