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'; import { Icon } from './icon.js'; import { loadPending } from './invite-link.js'; const PASSWORD_MIN_BITS = 60; const PASSWORD_MIN_LEN = 12; // The hub's USERNAME_MIN_LEN (api/users.py), checked here so the refusal comes // before a passphrase derivation rather than after it. const USERNAME_MIN_LEN = 8; // ── Passphrase field ───────────────────────────────────────────────────────── // // A passphrase this long is mistyped often enough that checking it is worth a // control, and the alternative people reach for otherwise is typing it into // the username box to read it back. The button is out of the tab order // (`tabindex="-1"`): everyone who is not reaching for it would pay a keystroke // between the passphrase and the submit button, and it does nothing a keyboard // user cannot do by other means. `type="button"` matters — a bare button in a // form submits it, which here would try to sign in on the first click. function PasswordInput({ value, onInput, placeholder, autocomplete, minlength = null, autofocus = false, }) { const [shown, setShown] = useState(false); const label = t(shown ? 'login.hide_password' : 'login.show_password'); return html`
`; } // ── 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); // Back to the invitation that sent them here, if one is waiting. navigate(loadPending() ? '/invite' : '/'); } catch (err) { if (err.message === 'email_verification_required') { setPendingVerif(true); } else if (err.message === 'account_locked') { setError(t('login.locked', { minutes: Math.max(1, Math.ceil((err.retryAfter || 60) / 60)), })); } else if (err.message === 'Invalid credentials') { setError(t('login.invalid')); } else { setError(err.message); } } finally { setLoading(false); } }; return html`
${!platform.isNative && html`<${WelcomeLinks} />`}
${!platform.isNative && html`<${WelcomePitch} />`}
`; } // What MeshBay is, beside the sign-in form. Browser only: inside the desktop // application the reader has already downloaded it, and the links point at the // project's own site rather than at whichever hub the application is set to. // // Written for somebody who is not in IT: what it does for them first, how it // works in three steps, and privacy said once, plainly. Every sentence is held // to MESHBAY_DESIGN.md §2.3 — "your content never passes through meshbay.org" // is a claim the design makes; "meshbay.org cannot read anything" is one it // forbids (T3). "Encrypted all the way" means device to node, as §2.3 defines. const WELCOME_APPS = [ ['chat', 'welcome.app_chat'], ['image', 'welcome.app_photos'], ['video', 'welcome.app_media'], ['play', 'welcome.app_video'], ['music', 'welcome.app_music'], ]; const WELCOME_STEPS = [ ['home', 'welcome.step_home'], ['globe', 'welcome.step_anywhere'], ['user', 'welcome.step_share'], ]; const WELCOME_USES = [ ['chat', 'welcome.use_chat'], ['image', 'welcome.use_photos'], ['cast', 'welcome.use_media'], ['pencil', 'welcome.use_apps'], ]; const WELCOME_BADGES = ['welcome.badge_free', 'welcome.badge_open', 'welcome.badge_no_ads', 'welcome.badge_no_tracking']; const REPO = 'https://git.meshbay.org/meshbay.git/about/'; const WELCOME_DOCS = [ ['folder', 'welcome.docs_source', [['welcome.docs_repo', REPO]]], ['user', 'welcome.docs_user', [ ['welcome.docs_quickstart', `${REPO}docs/QUICKSTART.md`], ['welcome.docs_userguide', `${REPO}docs/USERGUIDE.md`]]], ['gear', 'welcome.docs_devel', [ ['welcome.docs_design', `${REPO}docs/MESHBAY_DESIGN.md`], ['welcome.docs_protocol', `${REPO}docs/MESHBAY_NODE_PROTOCOL.md`]]], ]; // Under the sign-in form rather than at the foot of the text: on a desktop the // text column runs far below the form, and these were the last thing on it. function WelcomeLinks() { return html` `; } function WelcomePitch() { return html`

${t('welcome.title')}

${t('welcome.lead')}

${t('welcome.how_title')}

    ${WELCOME_STEPS.map(([icon, key]) => html`
  1. <${Icon} name=${icon} /> ${t(key)}
  2. `)}

${t('welcome.uses_title')}

<${Icon} name="shield" />

${t('welcome.private_title')}

${t('welcome.private_body')}

    ${WELCOME_BADGES.map(key => html`
  • <${Icon} name="check" />${t(key)}
  • `)}

${t('welcome.docs_title')}

`; } 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 (name.length < USERNAME_MIN_LEN) { setError(t('register.err_username_len', { n: USERNAME_MIN_LEN })); return; } 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/MESHBAY_DESIGN.md §3.6): 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')}

${loadPending() && html`

${t('invite.after_register')}

`} ${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 /> <${PasswordInput} placeholder=${t('register.password')} value=${password} onInput=${e => setPassword(e.target.value)} autocomplete="new-password" minlength="8" />

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

<${PasswordInput} placeholder=${t('register.confirm')} value=${confirm} onInput=${e => setConfirm(e.target.value)} autocomplete="new-password" /> ${captcha.widget} ${error && html`
${error}
`}
`; } // ── Passphrase reset — Flow B (docs/MESHBAY_DESIGN.md §3.6) ───────────────── // // 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}
`}
`}
`; }