From c6fd7ea89b6e0a96eb1d81989de891b4768b1044 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 31 Aug 2026 17:19:17 +0200 Subject: feat: email verification for registration, email change, and invitations Registration now creates a pending account and sends a 6-digit code via email; the account activates only after verification. Email changes on the profile page follow the same flow. Group invitations send a notification email to the invitee (without revealing their address to the inviter) containing the invite code and hub link. Backend: blind HMAC-SHA256 email index for uniqueness without decryption, mail.py for localhost Postfix delivery, verification endpoints, cleanup of expired codes and stale pending accounts, startup backfill of email_hash for existing users. Frontend: 3-phase register page, inline email change verification on profile, invite-notify call with status display. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 --- .../src/meshbay_hub/static/profile-page.js | 71 ++++++++++++++++++---- 1 file changed, 60 insertions(+), 11 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/profile-page.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index 3d3e578..ce22116 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -15,6 +15,9 @@ export function ProfilePage({ user, onLogout }) { const [emailEditing, setEmailEditing] = useState(false); const [emailSaving, setEmailSaving] = useState(false); const [emailStatus, setEmailStatus] = useState(''); + const [emailVerifyPending, setEmailVerifyPending] = useState(false); + const [emailCode, setEmailCode] = useState(''); + const [emailVerifying, setEmailVerifying] = useState(false); const [pinCount, setPinCount] = useState( () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); const [delOpen, setDelOpen] = useState(false); @@ -63,13 +66,18 @@ export function ProfilePage({ user, onLogout }) { setEmailSaving(true); setEmailStatus(''); try { - await hubFetch('/v1/users/me', { + const resp = await hubFetch('/v1/users/me', { method: 'PATCH', token: user.token, body: { email: val }, }); - setEmail(val); - setEmailEditing(false); - setEmailStatus(t('settings.email_saved')); - setTimeout(() => setEmailStatus(''), 3000); + if (resp.email_verification_required) { + setEmailVerifyPending(true); + setEmailStatus(t('settings.email_code_sent')); + } else { + setEmail(val); + setEmailEditing(false); + setEmailStatus(t('settings.email_saved')); + setTimeout(() => setEmailStatus(''), 3000); + } } catch (e) { setEmailStatus(e.message); } finally { @@ -77,6 +85,27 @@ export function ProfilePage({ user, onLogout }) { } }, [emailDraft, email, user.token]); + const verifyEmailChange = useCallback(async () => { + if (!emailCode.trim()) return; + setEmailVerifying(true); + setEmailStatus(''); + try { + const resp = await hubFetch('/v1/users/verify-email-change', { + method: 'POST', token: user.token, body: { code: emailCode.trim() }, + }); + setEmail(resp.email || emailDraft.trim()); + setEmailEditing(false); + setEmailVerifyPending(false); + setEmailCode(''); + setEmailStatus(t('settings.email_verified')); + setTimeout(() => setEmailStatus(''), 3000); + } catch (e) { + setEmailStatus(e.message); + } finally { + setEmailVerifying(false); + } + }, [emailCode, emailDraft, user.token]); + const submitNodeKey = useCallback(async () => { const key = nodeKey.trim(); if (!key) return; @@ -110,16 +139,19 @@ export function ProfilePage({ user, onLogout }) {
${t('settings.email')} ${emailEditing - ? html` + ? html` setEmailDraft(e.target.value)} onKeyDown=${e => e.key === 'Enter' && saveEmail()} + disabled=${emailVerifyPending} style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> - - + ${!emailVerifyPending && html` + + + `} ` : html` ${email || '—'} @@ -128,6 +160,23 @@ export function ProfilePage({ user, onLogout }) { ` }
+ ${emailVerifyPending && html` +
+ setEmailCode(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && verifyEmailChange()} + inputmode="numeric" maxlength="6" + style="width:120px;text-align:center;font-size:1.1em;letter-spacing:0.2em" /> + + +
+ `} ${emailStatus && html`

${emailStatus}

`} -- cgit v1.2.3