From b6c15f35d570d4f54901b811654991847502ca82 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 11:06:47 +0200 Subject: feat(hub): reCAPTCHA v2 on Register and Password Reset pages Server-side verification module, CaptchaConfig in hub.toml, captcha_site_key exposed via /v1/hub/info, useCaptcha() hook in the SPA with stable DOM rendering (strength bar always present to avoid Preact re-ordering the captcha widget). Native clients (auth_key path) skip captcha. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 --- .../src/meshbay_hub/static/auth-page.js | 111 ++++++++++++++++++--- 1 file changed, 95 insertions(+), 16 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/auth-page.js') 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 7c92d52..df08bc4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -1,5 +1,5 @@ import { - html, useState, + html, useState, useEffect, useRef, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { @@ -10,6 +10,75 @@ 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; @@ -142,6 +211,7 @@ export function RegisterPage() { const [recoverySaved, setRecoverySaved] = useState(false); const [recoveryCopied, setRecoveryCopied] = useState(false); const [emailRecovery, setEmailRecovery] = useState(true); + const captcha = useCaptcha(); const onSubmit = async (e) => { e.preventDefault(); @@ -177,7 +247,11 @@ export function RegisterPage() { } else { await hubFetch('/v1/users/register', { method: 'POST', - body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, + body: { + username: name, email, password, + pk_user_ed25519: '', pk_user_x25519: '', + captcha_token: captcha.token, + }, }); setPhase('verify'); } @@ -322,18 +396,16 @@ export function RegisterPage() { setPassword(e.target.value)} autocomplete="new-password" required minlength="8" /> - ${password && html` -
-
-
-
-

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

+
+
+
- `} +

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

+
setConfirm(e.target.value)} autocomplete="new-password" required /> @@ -343,8 +415,9 @@ export function RegisterPage() { onChange=${e => setEmailRecovery(e.target.checked)} /> ${t('register.recovery_email_opt')} + ${captcha.widget} ${error && html`
${error}
`} - @@ -375,6 +448,7 @@ export function ResetPasswordPage({ onLogin }) { const [busy, setBusy] = useState(false); const [progress, setProgress] = useState(null); const [result, setResult] = useState(null); + const captcha = useCaptcha(); const requestCode = async (e) => { e.preventDefault(); @@ -384,7 +458,10 @@ export function ResetPasswordPage({ onLogin }) { try { await hubFetch('/v1/users/password/reset-request', { method: 'POST', - body: { username: username.trim(), email: email.trim() }, + body: { + username: username.trim(), email: email.trim(), + captcha_token: captcha.token, + }, }); setPhase('form'); } catch (err) { @@ -493,8 +570,10 @@ export function ResetPasswordPage({ onLogin }) { setEmail(e.target.value)} autocomplete="email" required /> + ${captcha.widget} ${error && html`
${error}
`} - + `} ${phase === 'form' && html` -- cgit v1.2.3