aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 11:06:47 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 11:06:47 +0200
commitb6c15f35d570d4f54901b811654991847502ca82 (patch)
treea3d0a42c3b48aa7ac618094cedc59b5b3f329376 /packages/meshbay-hub/src/meshbay_hub/static
parent1c8eb6577e36e1e4a150afd0cdda8d283172385e (diff)
downloadmeshbay-b6c15f35d570d4f54901b811654991847502ca82.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js111
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
11 files changed, 115 insertions, 16 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 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`<div ref=${containerRef}
+ style="display:flex;justify-content:center;margin:12px 0"></div>`
+ : 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() {
<input type="password" placeholder="${t('register.password')}" value=${password}
onInput=${e => setPassword(e.target.value)}
autocomplete="new-password" required minlength="8" />
- ${password && html`
- <div style="margin:-4px 0 10px">
- <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden">
- <div style=${`height:100%;width:${Math.min(100, passwordBits(password) / 100 * 100)}%;
- background:${passwordBits(password) < PASSWORD_MIN_BITS ? 'var(--error)'
- : passwordBits(password) < 80 ? 'var(--yellow, #f59e0b)' : 'var(--success)'}`}></div>
- </div>
- <p style="font-size:0.8em;color:var(--text-dim);margin-top:4px">
- ${t('register.strength', { bits: passwordBits(password) })}
- </p>
+ <div style=${`margin:-4px 0 10px;${password ? '' : 'visibility:hidden;height:0;margin:0;overflow:hidden'}`}>
+ <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden">
+ <div style=${`height:100%;width:${Math.min(100, passwordBits(password) / 100 * 100)}%;
+ background:${passwordBits(password) < PASSWORD_MIN_BITS ? 'var(--error)'
+ : passwordBits(password) < 80 ? 'var(--yellow, #f59e0b)' : 'var(--success)'}`}></div>
</div>
- `}
+ <p style="font-size:0.8em;color:var(--text-dim);margin-top:4px">
+ ${t('register.strength', { bits: passwordBits(password) })}
+ </p>
+ </div>
<input type="password" placeholder="${t('register.confirm')}" value=${confirm}
onInput=${e => setConfirm(e.target.value)}
autocomplete="new-password" required />
@@ -343,8 +415,9 @@ export function RegisterPage() {
onChange=${e => setEmailRecovery(e.target.checked)} />
<span>${t('register.recovery_email_opt')}</span>
</label>
+ ${captcha.widget}
${error && html`<div class="error-msg">${error}</div>`}
- <button type="submit" disabled=${loading}>
+ <button type="submit" disabled=${loading || (captcha.enabled && !captcha.token)}>
${loading ? t('register.loading') : t('register.submit')}
</button>
</form>
@@ -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 }) {
<input type="email" placeholder="${t('register.email')}" value=${email}
onInput=${e => setEmail(e.target.value)}
autocomplete="email" required />
+ ${captcha.widget}
${error && html`<div class="error-msg">${error}</div>`}
- <button type="submit" disabled=${busy}>${t('reset.send_code')}</button>
+ <button type="submit" disabled=${busy || (captcha.enabled && !captcha.token)}>
+ ${t('reset.send_code')}</button>
</form>`}
${phase === 'form' && html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 363df86..fcd138d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -92,6 +92,8 @@ export default {
'register.strength': 'Stärke: etwa {bits} Bit. Sie schützt die Kopie Ihrer '
+ 'Schlüssel, die auf den Nodes liegt, denen Sie beitreten — es lohnt sich also, '
+ 'sie gut zu wählen.',
+ 'captcha.required': 'Bitte das Captcha ausfüllen.',
+ 'captcha.failed': 'Captcha-Überprüfung fehlgeschlagen — bitte erneut versuchen.',
// Home
'home.welcome': 'Willkommen bei MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 14a42a9..a265527 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -93,6 +93,8 @@ export default {
+ 'your keys where they are stored — a few unrelated words work well.',
'register.strength': 'Strength: about {bits} bits. This protects the copy of '
+ 'your keys kept on the nodes you join, so it is worth getting right.',
+ 'captcha.required': 'Please complete the captcha.',
+ 'captcha.failed': 'Captcha verification failed — please try again.',
// Home
'home.welcome': 'Welcome to MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index ab1829a..1505ffe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -90,6 +90,8 @@ export default {
+ 'relación entre sí funcionan muy bien.',
'register.strength': 'Solidez: unos {bits} bits. Protege la copia de sus claves que '
+ 'se guarda en los nodes a los que se une, así que conviene acertar.',
+ 'captcha.required': 'Por favor, complete el captcha.',
+ 'captcha.failed': 'Verificación del captcha fallida — inténtelo de nuevo.',
// Home
'home.welcome': 'Bienvenido a MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index bfa707c..95729e5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -91,6 +91,8 @@ export default {
'register.strength': 'Robustesse : environ {bits} bits. Elle protège la copie de '
+ 'vos clés conservée sur les nodes que vous rejoignez, cela vaut donc la peine '
+ 'de la soigner.',
+ 'captcha.required': 'Veuillez compléter le captcha.',
+ 'captcha.failed': 'Vérification du captcha échouée — veuillez réessayer.',
// Home
'home.welcome': 'Bienvenue sur MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 0f92692..faf0592 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -91,6 +91,8 @@ export default {
+ 'legame tra loro funziona bene.',
'register.strength': 'Robustezza: circa {bits} bit. Protegge la copia delle sue '
+ 'chiavi conservata sui node a cui aderisce, quindi vale la pena sceglierla con cura.',
+ 'captcha.required': 'Completa il captcha.',
+ 'captcha.failed': 'Verifica captcha fallita — riprova.',
// Home
'home.welcome': 'Benvenuto in MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 1e5a9f5..3a354de 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -89,6 +89,8 @@ export default {
+ 'その鍵を守るものです。互いに関係のない単語をいくつか並べると効果的です。',
'register.strength': '強度:約 {bits} ビット。参加している各 node に保管される鍵の'
+ 'コピーを守るものですので、しっかり決めておく価値があります。',
+ 'captcha.required': 'キャプチャを完了してください。',
+ 'captcha.failed': 'キャプチャの検証に失敗しました — もう一度お試しください。',
// Home
'home.welcome': 'MeshBay へようこそ',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 3cd34c3..d66fafd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -92,6 +92,8 @@ export default {
'register.strength': 'Sterkte: ongeveer {bits} bits. Ze beschermt de kopie van uw '
+ 'sleutels die op de nodes staat waar u zich bij aansluit, dus het loont om er '
+ 'even bij stil te staan.',
+ 'captcha.required': 'Vul de captcha in.',
+ 'captcha.failed': 'Captchaverificatie mislukt — probeer het opnieuw.',
// Home
'home.welcome': 'Welkom bij MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 10fe306..74a4dac 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -96,6 +96,8 @@ export default {
+ 'bardzo dobrze.',
'register.strength': 'Siła: około {bits} bitów. Chroni kopię kluczy przechowywaną na '
+ 'nodes, do których się dołącza, więc warto ją dobrać starannie.',
+ 'captcha.required': 'Proszę wypełnić captcha.',
+ 'captcha.failed': 'Weryfikacja captcha nie powiodła się — spróbuj ponownie.',
// Home
'home.welcome': 'Witamy w MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 9d69b85..e478133 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -92,6 +92,8 @@ export default {
+ 'entre si funcionam muito bem.',
'register.strength': 'Robustez: cerca de {bits} bits. Ela protege a cópia das suas '
+ 'chaves mantida nos nodes dos quais você participa, então vale a pena caprichar.',
+ 'captcha.required': 'Por favor, complete o captcha.',
+ 'captcha.failed': 'Verificação do captcha falhou — tente novamente.',
// Home
'home.welcome': 'Bem-vindo ao MeshBay',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index fc141c2..1d537b5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -89,6 +89,8 @@ export default {
+ '几个互不相关的词就很好用。',
'register.strength': '强度:约 {bits} 位。它保护的是您所加入的各个 node 上保存的密钥副本,'
+ '因此值得认真设置。',
+ 'captcha.required': '请完成验证码。',
+ 'captcha.failed': '验证码验证失败——请重试。',
// Home
'home.welcome': '欢迎使用 MeshBay',