summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-31 17:19:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-31 17:19:17 +0200
commitc6fd7ea89b6e0a96eb1d81989de891b4768b1044 (patch)
tree12e869044c80c889f83b588210cc0ac500cd7a6a /packages/meshbay-hub
parentf4c6628c8e85513d9fd110ead95682368a15a0fd (diff)
downloadmeshbay-c6fd7ea89b6e0a96eb1d81989de891b4768b1044.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py53
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py235
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8c9_add_email_verification.py53
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py97
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js86
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js71
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py28
-rw-r--r--packages/meshbay-hub/tests/conftest.py12
22 files changed, 923 insertions, 123 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 49902de..fcf0360 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -7,7 +7,8 @@ from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub import hub_settings
+from meshbay_hub import hub_settings, mail
+from meshbay_hub.auth import decrypt_email
from meshbay_hub.api.deps import get_current_user, require_user_scope
from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
@@ -671,3 +672,53 @@ async def delete_group(
return {"status": "deleted", "group_id": group_id}
+class InviteNotifyRequest(BaseModel):
+ username: str
+ code: str
+ group_name: str
+
+
+@router.post("/{group_id}/invite-notify")
+async def invite_notify(
+ group_id: str,
+ body: InviteNotifyRequest,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Send an invitation email to a member who was just invited.
+
+ The invite code was created on the node — the hub only knows about it
+ because the inviter's browser sends it here. The hub looks up the
+ invitee's encrypted email, decrypts it, and sends the notification.
+ The inviter never sees the email address.
+ """
+ group = await db.get(Group, group_id)
+ if not group:
+ raise HTTPException(status_code=404, detail="Group not found")
+ if group.admin_id != current_user.id:
+ raise HTTPException(status_code=403,
+ detail="Only the group owner can send invitations")
+
+ target = (await db.execute(
+ select(User).where(User.username == body.username))).scalar_one_or_none()
+ if not target:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ email = ""
+ try:
+ email = decrypt_email(target.email) if target.email else ""
+ except Exception:
+ pass
+
+ if not email:
+ return {"status": "no_email"}
+
+ try:
+ mail.send_invite_notification(
+ email, body.code, current_user.username, body.group_name)
+ except Exception:
+ return {"status": "send_failed"}
+
+ return {"status": "sent"}
+
+
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 7db3fc9..953ad1b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -1,39 +1,40 @@
"""User endpoints — /v1/users/*"""
import base64
-import time
import logging
+import secrets
+import time
import uuid
-from datetime import datetime, timezone, timedelta
+from datetime import datetime, timedelta, timezone
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
-from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, field_validator
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
+from meshbay_hub import mail
+from meshbay_hub.api.deps import get_current_user, require_user_scope
+from meshbay_hub.api.middleware import limiter
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.auth import (
current_pw_version,
- decode_access_token,
decrypt_email,
encrypt_email,
generate_refresh_token,
+ hash_email_blind,
hash_password,
hash_refresh_token,
- hub_public_key_pem,
issue_access_token,
pw_needs_rehash,
verify_password,
)
-from meshbay_hub.api.middleware import limiter
-from meshbay_hub.api.netutil import client_ip
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
- Group, GroupMember, IPLog, Node, Notification, RefreshToken, User,
- UserDevice, UserPreference,
+ EmailVerification, Group, GroupMember, IPLog, Node, Notification,
+ RefreshToken, User, UserDevice, UserPreference,
)
-from meshbay_hub.api.deps import get_current_user, require_user_scope
log = logging.getLogger(__name__)
@@ -51,6 +52,13 @@ def _ttl() -> int:
def _refresh_ttl() -> int:
return _cfg.jwt.refresh_token_ttl if _cfg else 86400 * 30
+VERIFICATION_TTL = 86400 # 24 hours
+VERIFICATION_MAX_ATTEMPTS = 10
+
+
+def _generate_code() -> str:
+ return f"{secrets.randbelow(1_000_000):06d}"
+
# ── Models ────────────────────────────────────────────────────────────────────
@@ -108,36 +116,43 @@ async def register(
request: Request,
db: AsyncSession = Depends(get_db),
):
+ eh = hash_email_blind(body.email)
+
existing = await db.execute(
select(User).where(User.username == body.username))
- if existing.scalar_one_or_none():
+ found = existing.scalar_one_or_none()
+
+ if found:
+ if found.status == "pending" and found.email_hash == eh:
+ # Same person retrying before validation — resend a code
+ await _create_and_send_verification(db, found, body.email, eh)
+ await db.commit()
+ return {"user_id": found.id, "email_verification_required": True}
raise HTTPException(status_code=409, detail="Username already taken")
+ # Email uniqueness (only active or pending accounts)
+ dup = await db.execute(
+ select(User).where(User.email_hash == eh, User.status.in_(["active", "pending"])))
+ if dup.scalar_one_or_none():
+ raise HTTPException(status_code=409, detail="Email already in use")
+
credential = body.auth_key or body.password
if not credential:
raise HTTPException(status_code=400, detail="auth_key or password required")
pw_hash, pw_salt = hash_password(credential)
- # auth_key → pw_version 3 (password split); raw password → pw_version 2 (legacy)
pw_ver = current_pw_version() if body.auth_key else 2
hub_id = _cfg.identity.id if _cfg else "meshbay.org"
user = User(
username=body.username,
email=encrypt_email(body.email),
+ email_hash=eh,
pw_hash=pw_hash,
pw_salt=pw_salt,
pw_version=pw_ver,
hub_id=hub_id,
)
db.add(user)
- # flush assigns user.id so the log row can be attributed directly.
- #
- # Finding M6: this used to insert the row with a NULL user_id and then run
- # UPDATE ip_logs SET user_id = <new user> WHERE user_id IS NULL
- # which claimed *every* unattributed row in the table — failed logins for other
- # usernames, other registrations racing this one — and stamped them with the
- # account just created. For logs retained a year to answer legal requests, that
- # attributed other people's connections to the wrong person.
await db.flush()
db.add(IPLog(
user_id=user.id,
@@ -145,10 +160,85 @@ async def register(
ip_address=client_ip(request),
detail=body.username,
))
+
+ await _create_and_send_verification(db, user, body.email, eh)
await db.commit()
- await db.refresh(user)
- return {"user_id": user.id}
+ return {"user_id": user.id, "email_verification_required": True}
+
+
+async def _create_and_send_verification(
+ db: AsyncSession, user: User, email: str, eh: str,
+) -> None:
+ user.status = "pending"
+ # Invalidate any previous pending verification for this user
+ prev = await db.execute(
+ select(EmailVerification).where(
+ EmailVerification.user_id == user.id,
+ EmailVerification.purpose == "registration",
+ EmailVerification.verified_at.is_(None),
+ ))
+ for old in prev.scalars().all():
+ await db.delete(old)
+
+ code = _generate_code()
+ db.add(EmailVerification(
+ email_hash=eh,
+ code=code,
+ purpose="registration",
+ user_id=user.id,
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=VERIFICATION_TTL),
+ ))
+ await db.flush()
+ mail.send_verification_code(email, code)
+
+
+class VerifyEmailRequest(BaseModel):
+ email: str
+ code: str
+
+
+@router.post("/verify-email")
+@limiter.limit("10/minute")
+async def verify_email(
+ body: VerifyEmailRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ """Verify a registration email with the code received by mail."""
+ eh = hash_email_blind(body.email)
+ now = datetime.now(timezone.utc)
+
+ result = await db.execute(
+ select(EmailVerification).where(
+ EmailVerification.email_hash == eh,
+ EmailVerification.purpose == "registration",
+ EmailVerification.verified_at.is_(None),
+ ).order_by(EmailVerification.created_at.desc()))
+ verif = result.scalar_one_or_none()
+
+ if not verif:
+ raise HTTPException(status_code=404,
+ detail="No pending verification for this email")
+
+ if verif.expires_at.replace(tzinfo=timezone.utc) < now:
+ raise HTTPException(status_code=410, detail="Verification code expired")
+
+ if verif.attempts >= VERIFICATION_MAX_ATTEMPTS:
+ raise HTTPException(status_code=429, detail="Too many attempts")
+
+ verif.attempts += 1
+
+ if verif.code != body.code.strip():
+ await db.commit()
+ raise HTTPException(status_code=400, detail="Invalid code")
+
+ verif.verified_at = now
+ user = await db.get(User, verif.user_id)
+ if user and user.status == "pending":
+ user.status = "active"
+ await db.commit()
+ return {"status": "verified"}
@router.post("/login")
@@ -203,6 +293,8 @@ async def login(
user.pw_salt = new_salt
user.pw_version = 2
+ if user.status == "pending":
+ raise HTTPException(status_code=403, detail="email_verification_required")
if user.status != "active":
raise HTTPException(status_code=403, detail=f"Account {user.status}")
@@ -365,6 +457,8 @@ async def device_auth(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
+ if user.status == "pending":
+ raise HTTPException(status_code=403, detail="email_verification_required")
if user.status != "active":
raise HTTPException(status_code=403, detail=f"Account {user.status}")
@@ -514,21 +608,112 @@ async def update_profile(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ pending_email = None
if body.email is not None:
- current_user.email = encrypt_email(body.email)
+ new_email = body.email.strip()
+ eh = hash_email_blind(new_email)
+
+ # Check that no other active/pending account uses this email
+ dup = await db.execute(
+ select(User).where(
+ User.email_hash == eh,
+ User.id != current_user.id,
+ User.status.in_(["active", "pending"]),
+ ))
+ if dup.scalar_one_or_none():
+ raise HTTPException(status_code=409, detail="Email already in use")
+
+ # Invalidate previous pending email_change verifications for this user
+ prev = await db.execute(
+ select(EmailVerification).where(
+ EmailVerification.user_id == current_user.id,
+ EmailVerification.purpose == "email_change",
+ EmailVerification.verified_at.is_(None),
+ ))
+ for old in prev.scalars().all():
+ await db.delete(old)
+
+ code = _generate_code()
+ db.add(EmailVerification(
+ email_hash=eh,
+ email_encrypted=encrypt_email(new_email),
+ code=code,
+ purpose="email_change",
+ user_id=current_user.id,
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=VERIFICATION_TTL),
+ ))
+ await db.flush()
+ mail.send_email_change_code(new_email, code)
+ pending_email = new_email
+
await db.commit()
email = ""
try:
email = decrypt_email(current_user.email) if current_user.email else ""
except Exception:
pass
- return {
+ resp = {
"user_id": current_user.id,
"username": current_user.username,
"email": email,
"role": current_user.role,
"status": current_user.status,
}
+ if pending_email:
+ resp["email_verification_required"] = True
+ return resp
+
+
+class VerifyEmailChangeRequest(BaseModel):
+ code: str
+
+
+@router.post("/verify-email-change")
+@limiter.limit("10/minute")
+async def verify_email_change(
+ body: VerifyEmailChangeRequest,
+ request: Request,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Confirm an email change with the code sent to the new address."""
+ now = datetime.now(timezone.utc)
+
+ result = await db.execute(
+ select(EmailVerification).where(
+ EmailVerification.user_id == current_user.id,
+ EmailVerification.purpose == "email_change",
+ EmailVerification.verified_at.is_(None),
+ ).order_by(EmailVerification.created_at.desc()))
+ verif = result.scalar_one_or_none()
+
+ if not verif:
+ raise HTTPException(status_code=404,
+ detail="No pending email change")
+
+ if verif.expires_at.replace(tzinfo=timezone.utc) < now:
+ raise HTTPException(status_code=410, detail="Verification code expired")
+
+ if verif.attempts >= VERIFICATION_MAX_ATTEMPTS:
+ raise HTTPException(status_code=429, detail="Too many attempts")
+
+ verif.attempts += 1
+
+ if verif.code != body.code.strip():
+ await db.commit()
+ raise HTTPException(status_code=400, detail="Invalid code")
+
+ verif.verified_at = now
+ current_user.email = verif.email_encrypted
+ current_user.email_hash = verif.email_hash
+ await db.commit()
+
+ email = ""
+ try:
+ email = decrypt_email(current_user.email) if current_user.email else ""
+ except Exception:
+ pass
+ return {"status": "verified", "email": email}
# ── User preferences ────────────────────────────────────────────────────────
@@ -677,6 +862,7 @@ async def erase_account(db: AsyncSession, user: User) -> dict:
await db.execute(delete(Notification).where(Notification.user_id == user.id))
await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id))
await db.execute(delete(Node).where(Node.user_id == user.id))
+ await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id))
username = user.username
# Before the name is released: the connection log is kept for its legal
@@ -686,6 +872,7 @@ async def erase_account(db: AsyncSession, user: User) -> dict:
update(IPLog).where(IPLog.user_id == user.id).values(username=username))
user.username = f"deleted-{user.id[:8]}"
user.email = ""
+ user.email_hash = None
user.pw_hash = b""
user.pw_salt = b""
user.pk_node_ed25519 = None
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 423ad03..1726ff4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -56,6 +56,37 @@ async def _sync_admin_roles(admin_usernames: list[str]) -> None:
await session.commit()
+async def _backfill_email_hashes() -> None:
+ """One-time backfill: compute email_hash for users that don't have one yet."""
+ import logging
+
+ from sqlalchemy import select
+
+ from meshbay_hub.auth import decrypt_email, hash_email_blind
+ from meshbay_hub.db.engine import get_session_factory
+ from meshbay_hub.db.models import User
+
+ log = logging.getLogger(__name__)
+ factory = get_session_factory()
+ async with factory() as session:
+ result = await session.execute(
+ select(User).where(User.email_hash.is_(None), User.email.isnot(None))
+ )
+ users = list(result.scalars().all())
+ if not users:
+ return
+ count = 0
+ for user in users:
+ try:
+ plain = decrypt_email(user.email)
+ user.email_hash = hash_email_blind(plain)
+ count += 1
+ except Exception:
+ log.warning("Could not backfill email_hash for user %s", user.id)
+ await session.commit()
+ log.info("Backfilled email_hash for %d users", count)
+
+
def create_app(cfg: HubConfig | None = None) -> FastAPI:
from meshbay_hub.config import load_config
if cfg is None:
@@ -73,6 +104,11 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
users_set_config(cfg)
set_admin_usernames(cfg.identity.admin_usernames)
+ await _backfill_email_hashes()
+
+ from meshbay_hub import mail as _mail
+ _mail.configure(cfg.identity.id)
+
if cfg.identity.admin_usernames:
await _sync_admin_roles(cfg.identity.admin_usernames)
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index 28f13a5..2bf59db 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -189,6 +189,21 @@ def decrypt_email(stored: str) -> str:
return AESGCM(_email_key).decrypt(nonce, ct, None).decode()
+def hash_email_blind(email: str) -> str:
+ """Deterministic HMAC-SHA256 of the lowercased email for uniqueness checks.
+
+ The encrypted email uses a random nonce, so two encryptions of the same
+ address produce different ciphertexts. This blind index allows a DB-level
+ uniqueness constraint without decrypting every row.
+ """
+ if _email_key is None:
+ raise RuntimeError("Hub keypair not loaded")
+ import hmac as _hmac
+ return _hmac.new(
+ _email_key, email.strip().lower().encode(), hashlib.sha256,
+ ).hexdigest()
+
+
# ── Refresh tokens ────────────────────────────────────────────────────────────
def generate_refresh_token() -> tuple[str, str]:
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8c9_add_email_verification.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8c9_add_email_verification.py
new file mode 100644
index 0000000..20ba29d
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8c9_add_email_verification.py
@@ -0,0 +1,53 @@
+"""add_email_verification
+
+Add email_hash blind index to users (for uniqueness without decryption),
+and the email_verifications table for registration, email change, and
+invitation codes.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-08-31 14:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'd4e5f6a7b8c9'
+down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b8'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column('users',
+ sa.Column('email_hash', sa.String(64), nullable=True))
+ op.create_index('ix_users_email_hash', 'users', ['email_hash'], unique=True)
+
+ op.create_table('email_verifications',
+ sa.Column('id', sa.String(36), nullable=False),
+ sa.Column('email_hash', sa.String(64), nullable=False),
+ sa.Column('email_encrypted', sa.String(256), nullable=True),
+ sa.Column('code', sa.String(16), nullable=False),
+ sa.Column('purpose', sa.String(16), nullable=False),
+ sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=True),
+ sa.Column('group_id', sa.String(36), sa.ForeignKey('groups.id'), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True),
+ server_default=sa.text("(now() at time zone 'utc')")),
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('attempts', sa.Integer, server_default='0'),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index('ix_email_verif_hash', 'email_verifications', ['email_hash'])
+ op.create_index('ix_email_verif_user', 'email_verifications', ['user_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_email_verif_user')
+ op.drop_index('ix_email_verif_hash')
+ op.drop_table('email_verifications')
+ op.drop_index('ix_users_email_hash', table_name='users')
+ op.drop_column('users', 'email_hash')
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index dbabfc2..e6c7d33 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -46,6 +46,7 @@ class User(Base):
# wrapped the group key for whatever it returned, which is finding H3; since
# the node does the wrapping, nothing reads a key from this directory. Keys
# are generated per node and pinned there (meshbay_node/roster.py).
+ email_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) # HMAC blind index
pk_node_ed25519: Mapped[str | None] = mapped_column(String(64), nullable=True) # node daemon key
hub_id: Mapped[str] = mapped_column(String(128), nullable=False)
role: Mapped[str] = mapped_column(String(16), default="user") # user|moderator|admin
@@ -60,6 +61,7 @@ class User(Base):
__table_args__ = (
Index("ix_users_username", "username"),
Index("ix_users_email", "email"),
+ Index("ix_users_email_hash", "email_hash", unique=True),
)
@@ -354,3 +356,32 @@ class IPLog(Base):
Index("ix_ip_logs_timestamp", "timestamp"),
Index("ix_ip_logs_ip", "ip_address"),
)
+
+
+class EmailVerification(Base):
+ """
+ One-time codes for email verification.
+
+ Purposes:
+ - registration: confirm the address at sign-up (account stays pending until verified)
+ - email_change: confirm a new address before it replaces the old one
+ - invitation: notify an invitee with the group invite code
+ """
+ __tablename__ = "email_verifications"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ email_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+ email_encrypted: Mapped[str | None] = mapped_column(String(256))
+ code: Mapped[str] = mapped_column(String(16), nullable=False)
+ purpose: Mapped[str] = mapped_column(String(16), nullable=False)
+ user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"))
+ group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id"))
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ attempts: Mapped[int] = mapped_column(Integer, default=0)
+
+ __table_args__ = (
+ Index("ix_email_verif_hash", "email_hash"),
+ Index("ix_email_verif_user", "user_id"),
+ )
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py
new file mode 100644
index 0000000..ce6e264
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/mail.py
@@ -0,0 +1,97 @@
+"""
+MeshBay Hub — email sending via localhost Postfix.
+
+Postfix listens on loopback only (inet_interfaces = loopback-only), so no
+authentication is needed. See docs/MAIL-SERVER.md for the full setup.
+"""
+
+import logging
+import smtplib
+from email.message import EmailMessage
+
+log = logging.getLogger(__name__)
+
+_hub_domain: str = "meshbay.org"
+_hub_url: str = "https://meshbay.org"
+
+
+def configure(hub_id: str) -> None:
+ global _hub_domain, _hub_url
+ _hub_domain = hub_id
+ _hub_url = f"https://{hub_id}"
+
+
+def _send(msg: EmailMessage) -> bool:
+ try:
+ with smtplib.SMTP("localhost", 25, timeout=10) as s:
+ s.send_message(msg)
+ return True
+ except Exception:
+ log.exception("Failed to send email to %s", msg["To"])
+ return False
+
+
+def send_verification_code(to: str, code: str) -> None:
+ msg = EmailMessage()
+ msg["From"] = f"noreply@{_hub_domain}"
+ msg["To"] = to
+ msg["Subject"] = f"MeshBay — Your verification code: {code}"
+ msg.set_content(
+ f"Your verification code is: {code}\n"
+ "\n"
+ "Enter this code to verify your email address.\n"
+ "This code expires in 24 hours.\n"
+ "\n"
+ "If you did not create a MeshBay account, ignore this email.\n"
+ "\n"
+ f"{_hub_url}\n"
+ )
+ _send(msg)
+ log.info("Verification code sent to %s", _mask_email(to))
+
+
+def send_email_change_code(to: str, code: str) -> None:
+ msg = EmailMessage()
+ msg["From"] = f"noreply@{_hub_domain}"
+ msg["To"] = to
+ msg["Subject"] = f"MeshBay — Confirm your new email: {code}"
+ msg.set_content(
+ f"Your verification code is: {code}\n"
+ "\n"
+ "Enter this code to confirm your new email address.\n"
+ "This code expires in 24 hours.\n"
+ "\n"
+ "If you did not request this change, ignore this email.\n"
+ "\n"
+ f"{_hub_url}\n"
+ )
+ _send(msg)
+ log.info("Email change code sent to %s", _mask_email(to))
+
+
+def send_invite_notification(
+ to: str, code: str, inviter: str, group_name: str,
+) -> None:
+ msg = EmailMessage()
+ msg["From"] = f"noreply@{_hub_domain}"
+ msg["To"] = to
+ msg["Subject"] = f"MeshBay — {inviter} invited you to {group_name}"
+ msg.set_content(
+ f"{inviter} invited you to the group \"{group_name}\" on MeshBay.\n"
+ "\n"
+ f"Your one-time code is: {code}\n"
+ "\n"
+ "Open the group and enter this code when prompted.\n"
+ "The code works once and expires in 7 days.\n"
+ "\n"
+ f"{_hub_url}\n"
+ )
+ _send(msg)
+ log.info("Invite notification sent to %s", _mask_email(to))
+
+
+def _mask_email(email: str) -> str:
+ local, _, domain = email.partition("@")
+ if len(local) <= 2:
+ return f"{'*' * len(local)}@{domain}"
+ return f"{local[0]}{'*' * (len(local) - 2)}{local[-1]}@{domain}"
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 1cca56f..860f890 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -68,16 +68,23 @@ export function LoginPage({ onLogin }) {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
+ const [pendingVerif, setPendingVerif] = useState(false);
+
const onSubmit = async (e) => {
e.preventDefault();
if (!username || !password) return;
setError('');
+ setPendingVerif(false);
setLoading(true);
try {
await onLogin(username, password);
navigate('/');
} catch (err) {
- setError(err.message);
+ if (err.message === 'email_verification_required') {
+ setPendingVerif(true);
+ } else {
+ setError(err.message);
+ }
} finally {
setLoading(false);
}
@@ -95,6 +102,11 @@ export function LoginPage({ onLogin }) {
onInput=${e => setPassword(e.target.value)}
autocomplete="current-password" required />
${error && html`<div class="error-msg">${error}</div>`}
+ ${pendingVerif && html`
+ <div class="error-msg" style="background:var(--bg-secondary);border-left:3px solid var(--yellow, #f59e0b)">
+ <p>${t('login.pending_verification')}</p>
+ </div>
+ `}
<button type="submit" disabled=${loading}>
${loading ? t('login.loading') : t('login.submit')}
</button>
@@ -113,8 +125,11 @@ export function RegisterPage() {
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
- const [success, setSuccess] = useState(false);
+ const [phase, setPhase] = useState('form'); // form | verify | done
const [loading, setLoading] = useState(false);
+ const [code, setCode] = useState('');
+ const [verifying, setVerifying] = useState(false);
+ const [resent, setResent] = useState(false);
const onSubmit = async (e) => {
e.preventDefault();
@@ -136,7 +151,7 @@ export function RegisterPage() {
body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
});
}
- setSuccess(true);
+ setPhase('verify');
} catch (err) {
setError(err.message);
} finally {
@@ -144,7 +159,53 @@ export function RegisterPage() {
}
};
- if (success) {
+ 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, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
+ });
+ setResent(true);
+ } catch (err) {
+ setError(err.message);
+ }
+ };
+
+ if (phase === 'done') {
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('register.verified_title')}</h2>
+ <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
+ ${t('register.verified_msg')}
+ </p>
+ <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a>
+ </div>
+ </div>
+ `;
+ }
+
+ if (phase === 'verify') {
return html`
<div class="page-center">
<div class="card login-card">
@@ -152,7 +213,22 @@ export function RegisterPage() {
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
${t('register.success_msg')}
</p>
- <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a>
+ <form onSubmit=${onVerify}>
+ <input type="text" placeholder="${t('register.code_placeholder')}"
+ value=${code} onInput=${e => 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`<div class="error-msg">${error}</div>`}
+ <button type="submit" disabled=${verifying}>
+ ${verifying ? t('register.verifying') : t('register.verify_btn')}
+ </button>
+ </form>
+ <div class="login-footer">
+ <button class="link-btn" onClick=${onResend}>${t('register.resend')}</button>
+ ${resent && html`<span style="color:var(--success);margin-left:8px">
+ ${t('register.resend_sent')}</span>`}
+ </div>
</div>
</div>
`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 4f55dfb..1c6ca71 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -793,7 +793,21 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
method: 'POST', token, body: {},
});
- setInviteCode({ username, code: result.code, expires: result.expires_at });
+ // Send an email notification to the invitee with the code.
+ // The hub decrypts their email server-side — the inviter never sees it.
+ let emailStatus = 'no_email';
+ try {
+ const notif = await hubFetch(`/v1/groups/${groupId}/invite-notify`, {
+ method: 'POST', token,
+ body: { username, code: result.code, group_name: group?.name || '' },
+ });
+ emailStatus = notif.status;
+ } catch { /* best effort */ }
+
+ setInviteCode({
+ username, code: result.code, expires: result.expires_at,
+ emailSent: emailStatus === 'sent',
+ });
setInviteUser('');
loadMembers();
} catch (err) {
@@ -831,6 +845,10 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<div class="success-msg" style="margin-bottom:8px">
<p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
<p class="code-display">${inviteCode.code}</p>
+ ${inviteCode.emailSent
+ ? html`<p style="color:var(--success)">${t('members.invite_email_sent')}</p>`
+ : html`<p style="color:var(--text-dim)">${t('members.invite_email_failed')}</p>`
+ }
<p>${t('members.invite_code_hint')}</p>
</div>
`}
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 8284527..909fec5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -40,9 +40,18 @@ export default {
'register.loading': 'Konto wird erstellt …',
'register.has_account': 'Sie haben bereits ein Konto?',
'register.login_link': 'Anmelden',
- 'register.success_title': 'Konto erstellt',
- 'register.success_msg': 'Sie können sich jetzt mit Ihren Zugangsdaten anmelden.',
+ 'register.success_title': 'E-Mail bestätigen',
+ 'register.success_msg': 'Ein Bestätigungscode wurde an Ihre E-Mail-Adresse gesendet. Geben Sie ihn unten ein, um Ihr Konto zu aktivieren.',
'register.go_login': 'Zur Anmeldung',
+ 'register.code_placeholder': 'Bestätigungscode',
+ 'register.verify_btn': 'Bestätigen',
+ 'register.verifying': 'Überprüfung…',
+ 'register.verified_title': 'E-Mail bestätigt',
+ 'register.verified_msg': 'Ihr Konto ist aktiv. Sie können sich jetzt anmelden.',
+ 'register.resend': 'Code erneut senden',
+ 'register.resend_sent': 'Ein neuer Code wurde gesendet.',
+ 'login.pending_verification': 'Ihre E-Mail-Adresse ist noch nicht bestätigt. Bitte prüfen Sie Ihr Postfach auf den Bestätigungscode.',
+ 'login.verify_link': 'E-Mail bestätigen',
'register.err_mismatch': 'Die Passwörter stimmen nicht überein',
'register.err_min_len': {
one: 'Verwenden Sie mindestens {n} Zeichen',
@@ -262,7 +271,12 @@ export default {
'settings.username': 'Benutzername',
'settings.email': 'Email',
'settings.email_save': 'Save',
- 'settings.email_saved': 'Email updated',
+ 'settings.email_saved': 'E-Mail aktualisiert',
+ 'settings.email_code_sent': 'Ein Bestätigungscode wurde an Ihre neue E-Mail-Adresse gesendet.',
+ 'settings.email_code_placeholder': 'Bestätigungscode',
+ 'settings.email_verify_btn': 'Bestätigen',
+ 'settings.email_verifying': 'Überprüfung…',
+ 'settings.email_verified': 'E-Mail-Adresse erfolgreich geändert.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
'settings.defaults': 'Standardwerte',
@@ -468,10 +482,11 @@ export default {
+ 'Code läuft nie über den Hub.',
'members.pair_btn': 'Koppeln',
'members.pair_success': 'Dieser Browser ist jetzt mit dem Node gekoppelt.',
- 'members.invite_code_ready': 'Einladungscode für {user} — schicken Sie ihn auf dem '
- + 'Weg, den Sie ohnehin nutzen. Er funktioniert einmal und läuft nie über den Hub.',
- 'members.invite_code_hint': 'Die eingeladene Person gibt ihn ein, wenn sie diese '
- + 'Gruppe zum ersten Mal öffnet. Sie müssen dann nicht online sein.',
+ 'members.invite_code_ready': 'Einladungscode für {user}:',
+ 'members.invite_email_sent': 'Eine E-Mail mit dem Code wurde an dieses Mitglied gesendet.',
+ 'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.',
+ 'members.invite_code_hint': 'Sie können diesen Code auch über einen anderen Kanal teilen (z. B. SMS). '
+ + 'Die eingeladene Person gibt ihn ein, wenn sie diese Gruppe zum ersten Mal öffnet.',
'transfers.title': 'Übertragungen',
'transfers.clear': 'Abgeschlossene entfernen',
'transfers.cancel': 'Abbrechen',
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 b98e032..a6f77b3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -43,9 +43,18 @@ export default {
'register.loading': 'Creating account...',
'register.has_account': 'Already have an account?',
'register.login_link': 'Login',
- 'register.success_title': 'Account created',
- 'register.success_msg': 'You can now log in with your credentials.',
+ 'register.success_title': 'Verify your email',
+ 'register.success_msg': 'A verification code has been sent to your email address. Enter it below to activate your account.',
'register.go_login': 'Go to login',
+ 'register.code_placeholder': 'Verification code',
+ 'register.verify_btn': 'Verify',
+ 'register.verifying': 'Verifying...',
+ 'register.verified_title': 'Email verified',
+ 'register.verified_msg': 'Your account is active. You can now log in.',
+ 'register.resend': 'Resend code',
+ 'register.resend_sent': 'A new code has been sent.',
+ 'login.pending_verification': 'Your email is not yet verified. Please check your inbox for the verification code.',
+ 'login.verify_link': 'Verify email',
'register.err_mismatch': 'Passwords do not match',
'register.err_min_len': {
one: 'Use at least {n} character',
@@ -260,6 +269,11 @@ export default {
'settings.email': 'Email',
'settings.email_save': 'Save',
'settings.email_saved': 'Email updated',
+ 'settings.email_code_sent': 'A verification code has been sent to your new email address.',
+ 'settings.email_code_placeholder': 'Verification code',
+ 'settings.email_verify_btn': 'Verify',
+ 'settings.email_verifying': 'Verifying...',
+ 'settings.email_verified': 'Email address changed successfully.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
'settings.defaults': 'Defaults',
@@ -547,10 +561,11 @@ export default {
+ 'never passes through the hub.',
'members.pair_btn': 'Pair',
'members.pair_success': 'This browser is now paired with the node.',
- 'members.invite_code_ready': 'Invitation code for {user} — send it to them the way '
- + 'you normally talk. It works once, and it never passes through the hub.',
- 'members.invite_code_hint': 'They enter it the first time they open this group. '
- + 'You do not need to be online then.',
+ 'members.invite_code_ready': 'Invitation code for {user}:',
+ 'members.invite_email_sent': 'An email with the code has been sent to this member.',
+ 'members.invite_email_failed': 'Could not send the email — share the code manually.',
+ 'members.invite_code_hint': 'You can also share this code via another channel (e.g. SMS). '
+ + 'They enter it the first time they open this group.',
'transfers.title': 'Transfers',
'transfers.clear': 'Clear finished',
'transfers.cancel': 'Cancel',
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 d0a7004..640eb91 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -39,9 +39,18 @@ export default {
'register.loading': 'Creando la cuenta...',
'register.has_account': '¿Ya tiene una cuenta?',
'register.login_link': 'Iniciar sesión',
- 'register.success_title': 'Cuenta creada',
- 'register.success_msg': 'Ya puede iniciar sesión con sus credenciales.',
+ 'register.success_title': 'Verifique su correo',
+ 'register.success_msg': 'Se ha enviado un código de verificación a su dirección de correo electrónico. Introdúzcalo a continuación para activar su cuenta.',
'register.go_login': 'Ir al inicio de sesión',
+ 'register.code_placeholder': 'Código de verificación',
+ 'register.verify_btn': 'Verificar',
+ 'register.verifying': 'Verificando…',
+ 'register.verified_title': 'Correo verificado',
+ 'register.verified_msg': 'Su cuenta está activa. Ya puede iniciar sesión.',
+ 'register.resend': 'Reenviar código',
+ 'register.resend_sent': 'Se ha enviado un nuevo código.',
+ 'login.pending_verification': 'Su correo electrónico aún no ha sido verificado. Revise su bandeja de entrada para obtener el código de verificación.',
+ 'login.verify_link': 'Verificar correo',
'register.err_mismatch': 'Las contraseñas no coinciden',
'register.err_min_len': {
one: 'Use al menos {n} carácter',
@@ -260,7 +269,12 @@ export default {
'settings.username': 'Nombre de usuario',
'settings.email': 'Email',
'settings.email_save': 'Save',
- 'settings.email_saved': 'Email updated',
+ 'settings.email_saved': 'Correo actualizado',
+ 'settings.email_code_sent': 'Se ha enviado un código de verificación a su nueva dirección de correo.',
+ 'settings.email_code_placeholder': 'Código de verificación',
+ 'settings.email_verify_btn': 'Verificar',
+ 'settings.email_verifying': 'Verificando…',
+ 'settings.email_verified': 'Dirección de correo cambiada con éxito.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
'settings.defaults': 'Valores predeterminados',
@@ -464,10 +478,11 @@ export default {
+ 'código nunca pasa por el hub.',
'members.pair_btn': 'Emparejar',
'members.pair_success': 'Este navegador ya está emparejado con el node.',
- 'members.invite_code_ready': 'Código de invitación para {user} — envíeselo por el '
- + 'medio que usen habitualmente. Funciona una sola vez y nunca pasa por el hub.',
- 'members.invite_code_hint': 'Lo introduce la primera vez que abre este grupo. No '
- + 'necesita que usted esté en línea en ese momento.',
+ 'members.invite_code_ready': 'Código de invitación para {user}:',
+ 'members.invite_email_sent': 'Se ha enviado un correo con el código a este miembro.',
+ 'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.',
+ 'members.invite_code_hint': 'También puede compartir este código por otro canal (ej. SMS). '
+ + 'Lo introduce la primera vez que abre este grupo.',
'transfers.title': 'Transferencias',
'transfers.clear': 'Borrar las terminadas',
'transfers.cancel': 'Cancelar',
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 1e7bb79..de6b7c8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -39,9 +39,18 @@ export default {
'register.loading': 'Création du compte...',
'register.has_account': 'Vous avez déjà un compte ?',
'register.login_link': 'Se connecter',
- 'register.success_title': 'Compte créé',
- 'register.success_msg': 'Vous pouvez maintenant vous connecter avec vos identifiants.',
+ 'register.success_title': 'Vérifiez votre e-mail',
+ 'register.success_msg': 'Un code de vérification a été envoyé à votre adresse e-mail. Saisissez-le ci-dessous pour activer votre compte.',
'register.go_login': 'Aller à la connexion',
+ 'register.code_placeholder': 'Code de vérification',
+ 'register.verify_btn': 'Vérifier',
+ 'register.verifying': 'Vérification...',
+ 'register.verified_title': 'E-mail vérifié',
+ 'register.verified_msg': 'Votre compte est actif. Vous pouvez maintenant vous connecter.',
+ 'register.resend': 'Renvoyer le code',
+ 'register.resend_sent': 'Un nouveau code a été envoyé.',
+ 'login.pending_verification': 'Votre e-mail n\'est pas encore vérifié. Consultez votre boîte de réception pour le code de vérification.',
+ 'login.verify_link': 'Vérifier l\'e-mail',
'register.err_mismatch': 'Les mots de passe ne correspondent pas',
'register.err_min_len': {
one: 'Utilisez au moins {n} caractère',
@@ -262,6 +271,11 @@ export default {
'settings.email': 'E-mail',
'settings.email_save': 'Enregistrer',
'settings.email_saved': 'E-mail mis à jour',
+ 'settings.email_code_sent': 'Un code de vérification a été envoyé à votre nouvelle adresse e-mail.',
+ 'settings.email_code_placeholder': 'Code de vérification',
+ 'settings.email_verify_btn': 'Vérifier',
+ 'settings.email_verifying': 'Vérification...',
+ 'settings.email_verified': 'Adresse e-mail modifiée avec succès.',
'settings.notif_global_disable': 'Désactiver toutes les notifications',
'settings.notif_global_hint': 'Quand activé, aucune notification n\x27est créée pour aucun groupe.',
'settings.defaults': 'Valeurs par défaut',
@@ -467,11 +481,11 @@ export default {
+ 'ici. Le code ne transite jamais par le hub.',
'members.pair_btn': 'Appairer',
'members.pair_success': 'Ce navigateur est désormais appairé avec le node.',
- 'members.invite_code_ready': 'Code d’invitation pour {user} — transmettez-le-lui '
- + 'par le moyen dont vous avez l’habitude. Il ne fonctionne qu’une fois, et ne '
- + 'transite jamais par le hub.',
- 'members.invite_code_hint': 'La personne le saisit la première fois qu’elle ouvre '
- + 'ce groupe. Vous n’avez pas besoin d’être en ligne à ce moment-là.',
+ 'members.invite_code_ready': "Code d’invitation pour {user} :",
+ 'members.invite_email_sent': "Un e-mail avec le code a été envoyé à ce membre.",
+ 'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.",
+ 'members.invite_code_hint': "Vous pouvez aussi transmettre ce code par un autre canal (ex. SMS). "
+ + "La personne le saisit la première fois qu’elle ouvre ce groupe.",
'transfers.title': 'Transferts',
'transfers.clear': 'Effacer les terminés',
'transfers.cancel': 'Annuler',
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 eec9b65..52f6279 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -40,9 +40,18 @@ export default {
'register.loading': "Creazione dell'account...",
'register.has_account': 'Ha già un account?',
'register.login_link': 'Accedi',
- 'register.success_title': 'Account creato',
- 'register.success_msg': 'Ora può accedere con le sue credenziali.',
+ 'register.success_title': 'Verifichi la sua e-mail',
+ 'register.success_msg': 'Un codice di verifica è stato inviato al suo indirizzo e-mail. Lo inserisca qui sotto per attivare il suo account.',
'register.go_login': "Vai all'accesso",
+ 'register.code_placeholder': 'Codice di verifica',
+ 'register.verify_btn': 'Verifica',
+ 'register.verifying': 'Verifica in corso…',
+ 'register.verified_title': 'E-mail verificata',
+ 'register.verified_msg': 'Il suo account è attivo. Ora può accedere.',
+ 'register.resend': 'Invia di nuovo il codice',
+ 'register.resend_sent': 'Un nuovo codice è stato inviato.',
+ 'login.pending_verification': 'Il suo indirizzo e-mail non è ancora verificato. Controlli la sua casella di posta per il codice di verifica.',
+ 'login.verify_link': 'Verifica e-mail',
'register.err_mismatch': 'Le password non coincidono',
'register.err_min_len': {
one: 'Usi almeno {n} carattere',
@@ -261,7 +270,12 @@ export default {
'settings.username': 'Nome utente',
'settings.email': 'Email',
'settings.email_save': 'Save',
- 'settings.email_saved': 'Email updated',
+ 'settings.email_saved': 'E-mail aggiornata',
+ 'settings.email_code_sent': 'Un codice di verifica è stato inviato al suo nuovo indirizzo e-mail.',
+ 'settings.email_code_placeholder': 'Codice di verifica',
+ 'settings.email_verify_btn': 'Verifica',
+ 'settings.email_verifying': 'Verifica in corso…',
+ 'settings.email_verified': 'Indirizzo e-mail modificato con successo.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
'settings.defaults': 'Valori predefiniti',
@@ -467,10 +481,11 @@ export default {
+ 'passa mai per l’hub.',
'members.pair_btn': 'Associa',
'members.pair_success': 'Questo browser è ora associato al node.',
- 'members.invite_code_ready': 'Codice di invito per {user} — glielo mandi con il '
- + 'mezzo che usate di solito. Funziona una volta sola e non passa mai per l’hub.',
- 'members.invite_code_hint': 'Lo inserirà la prima volta che aprirà questo gruppo. '
- + 'Non è necessario che lei sia online in quel momento.',
+ 'members.invite_code_ready': 'Codice di invito per {user}:',
+ 'members.invite_email_sent': "Un'e-mail con il codice è stata inviata a questo membro.",
+ 'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.",
+ 'members.invite_code_hint': 'Può anche condividere questo codice tramite un altro canale (es. SMS). '
+ + 'Lo inserirà la prima volta che aprirà questo gruppo.',
'transfers.title': 'Trasferimenti',
'transfers.clear': 'Rimuovi i completati',
'transfers.cancel': 'Annulla',
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 023ca33..75880fa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -40,9 +40,18 @@ export default {
'register.loading': 'アカウントを作成しています…',
'register.has_account': 'すでにアカウントをお持ちですか?',
'register.login_link': 'ログイン',
- 'register.success_title': 'アカウントを作成しました',
- 'register.success_msg': 'ご自身の資格情報でログインできるようになりました。',
+ 'register.success_title': 'メールアドレスを確認してください',
+ 'register.success_msg': '確認コードをメールアドレスに送信しました。以下に入力してアカウントを有効にしてください。',
'register.go_login': 'ログインへ進む',
+ 'register.code_placeholder': '確認コード',
+ 'register.verify_btn': '確認',
+ 'register.verifying': '確認中…',
+ 'register.verified_title': 'メール確認完了',
+ 'register.verified_msg': 'アカウントが有効になりました。ログインできます。',
+ 'register.resend': 'コードを再送信',
+ 'register.resend_sent': '新しいコードを送信しました。',
+ 'login.pending_verification': 'メールアドレスがまだ確認されていません。受信トレイで確認コードをご確認ください。',
+ 'login.verify_link': 'メールを確認',
'register.err_mismatch': 'パスワードが一致しません',
'register.err_min_len': {
other: '{n} 文字以上でご入力ください',
@@ -258,7 +267,12 @@ export default {
'settings.username': 'ユーザー名',
'settings.email': 'Email',
'settings.email_save': 'Save',
- 'settings.email_saved': 'Email updated',
+ 'settings.email_saved': 'メールアドレスを更新しました',
+ 'settings.email_code_sent': '新しいメールアドレスに確認コードを送信しました。',
+ 'settings.email_code_placeholder': '確認コード',
+ 'settings.email_verify_btn': '確認',
+ 'settings.email_verifying': '確認中…',
+ 'settings.email_verified': 'メールアドレスが正常に変更されました。',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
'settings.defaults': 'デフォルト',
@@ -459,10 +473,11 @@ export default {
+ 'このコードが hub を経由することはありません。',
'members.pair_btn': 'ペアリング',
'members.pair_success': 'このブラウザーは node とペアリングされました。',
- 'members.invite_code_ready': '{user} 宛ての招待コードです。'
- + 'いつもの連絡手段でお送りください。使えるのは 1 回だけで、hub を経由することはありません。',
- 'members.invite_code_hint': '相手がこのグループを初めて開いたときに入力します。'
- + 'そのときお客様がオンラインである必要はありません。',
+ 'members.invite_code_ready': '{user} 宛ての招待コード:',
+ 'members.invite_email_sent': 'このメンバーにコード付きのメールが送信されました。',
+ 'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。',
+ 'members.invite_code_hint': 'このコードは別の手段(SMSなど)でも共有できます。'
+ + '相手がこのグループを初めて開いたときに入力します。',
'transfers.title': '転送',
'transfers.clear': '完了分を消去',
'transfers.cancel': 'キャンセル',
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 7ee35ab..5b72314 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -40,9 +40,18 @@ export default {
'register.loading': 'Account wordt aangemaakt...',
'register.has_account': 'Hebt u al een account?',
'register.login_link': 'Aanmelden',
- 'register.success_title': 'Account aangemaakt',
- 'register.success_msg': 'U kunt zich nu met uw gegevens aanmelden.',
+ 'register.success_title': 'Verifieer uw e-mail',
+ 'register.success_msg': 'Er is een verificatiecode naar uw e-mailadres gestuurd. Voer deze hieronder in om uw account te activeren.',
'register.go_login': 'Naar het aanmelden',
+ 'register.code_placeholder': 'Verificatiecode',
+ 'register.verify_btn': 'Verifiëren',
+ 'register.verifying': 'Verifiëren…',
+ 'register.verified_title': 'E-mail geverifieerd',
+ 'register.verified_msg': 'Uw account is actief. U kunt zich nu aanmelden.',
+ 'register.resend': 'Code opnieuw verzenden',
+ 'register.resend_sent': 'Er is een nieuwe code verzonden.',
+ 'login.pending_verification': 'Uw e-mailadres is nog niet geverifieerd. Controleer uw inbox voor de verificatiecode.',
+ 'login.verify_link': 'E-mail verifiëren',
'register.err_mismatch': 'De wachtwoorden komen niet overeen',
'register.err_min_len': {
one: 'Gebruik minstens {n} teken',
@@ -261,9 +270,14 @@ export default {
'settings.profile': 'Profiel',
'settings.username': 'Gebruikersnaam',
'settings.email': 'Email',
- 'settings.email_save': 'Save',
- 'settings.email_saved': 'Email updated',
- 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.email_save': 'Opslaan',
+ 'settings.email_saved': 'E-mail bijgewerkt',
+ 'settings.email_code_sent': 'Er is een verificatiecode naar uw nieuwe e-mailadres gestuurd.',
+ 'settings.email_code_placeholder': 'Verificatiecode',
+ 'settings.email_verify_btn': 'Verifiëren',
+ 'settings.email_verifying': 'Verifiëren…',
+ 'settings.email_verified': 'E-mailadres succesvol gewijzigd.',
+ 'settings.notif_global_disable': 'Alle meldingen uitschakelen',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
'settings.defaults': 'Standaardwaarden',
'settings.default_tab': 'Default tab',
@@ -468,10 +482,11 @@ export default {
+ 'via de hub.',
'members.pair_btn': 'Koppelen',
'members.pair_success': 'Deze browser is nu aan de node gekoppeld.',
- 'members.invite_code_ready': 'Uitnodigingscode voor {user} — stuur die op de manier '
- + 'waarop u elkaar gewoonlijk spreekt. Hij werkt één keer en gaat nooit via de hub.',
- 'members.invite_code_hint': 'De persoon voert hem in de eerste keer dat deze groep '
- + 'wordt geopend. U hoeft dan niet online te zijn.',
+ 'members.invite_code_ready': 'Uitnodigingscode voor {user}:',
+ 'members.invite_email_sent': 'Er is een e-mail met de code naar dit lid gestuurd.',
+ 'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.',
+ 'members.invite_code_hint': 'U kunt deze code ook via een ander kanaal delen (bijv. sms). '
+ + 'De code werkt één keer en gaat nooit via de hub.',
'transfers.title': 'Overdrachten',
'transfers.clear': 'Voltooide wissen',
'transfers.cancel': 'Annuleren',
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 c221cea..bb2361e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -43,9 +43,18 @@ export default {
'register.loading': 'Tworzenie konta...',
'register.has_account': 'Konto już istnieje?',
'register.login_link': 'Zaloguj',
- 'register.success_title': 'Konto zostało założone',
- 'register.success_msg': 'Można się już zalogować przy użyciu swoich danych.',
+ 'register.success_title': 'Zweryfikuj swój e-mail',
+ 'register.success_msg': 'Kod weryfikacyjny został wysłany na Twój adres e-mail. Wpisz go poniżej, aby aktywować konto.',
'register.go_login': 'Przejdź do logowania',
+ 'register.code_placeholder': 'Kod weryfikacyjny',
+ 'register.verify_btn': 'Zweryfikuj',
+ 'register.verifying': 'Weryfikacja…',
+ 'register.verified_title': 'E-mail zweryfikowany',
+ 'register.verified_msg': 'Twoje konto jest aktywne. Możesz się teraz zalogować.',
+ 'register.resend': 'Wyślij kod ponownie',
+ 'register.resend_sent': 'Nowy kod został wysłany.',
+ 'login.pending_verification': 'Twój adres e-mail nie został jeszcze zweryfikowany. Sprawdź skrzynkę odbiorczą.',
+ 'login.verify_link': 'Zweryfikuj e-mail',
'register.err_mismatch': 'Hasła nie są zgodne',
'register.err_min_len': {
one: 'Proszę użyć co najmniej {n} znaku',
@@ -272,10 +281,15 @@ export default {
'settings.profile': 'Profil',
'settings.username': 'Nazwa użytkownika',
'settings.email': 'Email',
- 'settings.email_save': 'Save',
- 'settings.email_saved': 'Email updated',
- 'settings.notif_global_disable': 'Disable all notifications',
- 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.email_save': 'Zapisz',
+ 'settings.email_saved': 'E-mail zaktualizowany',
+ 'settings.email_code_sent': 'Kod weryfikacyjny został wysłany na nowy adres e-mail.',
+ 'settings.email_code_placeholder': 'Kod weryfikacyjny',
+ 'settings.email_verify_btn': 'Zweryfikuj',
+ 'settings.email_verifying': 'Weryfikacja…',
+ 'settings.email_verified': 'Adres e-mail został pomyślnie zmieniony.',
+ 'settings.notif_global_disable': 'Wyłącz wszystkie powiadomienia',
+ 'settings.notif_global_hint': 'Po włączeniu nie będą tworzone żadne powiadomienia dla żadnej grupy.',
'settings.defaults': 'Wartości domyślne',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
@@ -480,11 +494,11 @@ export default {
+ 'przechodzi przez hub.',
'members.pair_btn': 'Sparuj',
'members.pair_success': 'Ta przeglądarka jest już sparowana z node.',
- 'members.invite_code_ready': 'Kod zaproszenia dla użytkownika {user} — proszę '
- + 'przekazać go tak, jak zwykle się Państwo kontaktują. Działa jednorazowo i nigdy '
- + 'nie przechodzi przez hub.',
- 'members.invite_code_hint': 'Osoba zaproszona wpisze go, otwierając tę grupę po raz '
- + 'pierwszy. Nie trzeba być wtedy dostępnym.',
+ 'members.invite_code_ready': 'Kod zaproszenia dla użytkownika {user}:',
+ 'members.invite_email_sent': 'E-mail z kodem został wysłany do tego członka.',
+ 'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.',
+ 'members.invite_code_hint': 'Możesz też przekazać ten kod innym kanałem (np. SMS). '
+ + 'Kod działa jednorazowo i nigdy nie przechodzi przez hub.',
'transfers.title': 'Transfery',
'transfers.clear': 'Wyczyść zakończone',
'transfers.cancel': 'Anuluj',
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 89c76c2..3fbbb0b 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
@@ -41,9 +41,18 @@ export default {
'register.loading': 'Criando a conta...',
'register.has_account': 'Já tem uma conta?',
'register.login_link': 'Entrar',
- 'register.success_title': 'Conta criada',
- 'register.success_msg': 'Agora você pode entrar com suas credenciais.',
+ 'register.success_title': 'Verifique seu e-mail',
+ 'register.success_msg': 'Um código de verificação foi enviado para o seu e-mail. Digite-o abaixo para ativar sua conta.',
'register.go_login': 'Ir para o login',
+ 'register.code_placeholder': 'Código de verificação',
+ 'register.verify_btn': 'Verificar',
+ 'register.verifying': 'Verificando…',
+ 'register.verified_title': 'E-mail verificado',
+ 'register.verified_msg': 'Sua conta está ativa. Agora você pode entrar.',
+ 'register.resend': 'Reenviar código',
+ 'register.resend_sent': 'Um novo código foi enviado.',
+ 'login.pending_verification': 'Seu e-mail ainda não foi verificado. Verifique sua caixa de entrada.',
+ 'login.verify_link': 'Verificar e-mail',
'register.err_mismatch': 'As senhas não coincidem',
'register.err_min_len': {
one: 'Use pelo menos {n} caractere',
@@ -261,10 +270,15 @@ export default {
'settings.profile': 'Perfil',
'settings.username': 'Nome de usuário',
'settings.email': 'Email',
- 'settings.email_save': 'Save',
- 'settings.email_saved': 'Email updated',
- 'settings.notif_global_disable': 'Disable all notifications',
- 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.email_save': 'Salvar',
+ 'settings.email_saved': 'E-mail atualizado',
+ 'settings.email_code_sent': 'Um código de verificação foi enviado para o seu novo endereço de e-mail.',
+ 'settings.email_code_placeholder': 'Código de verificação',
+ 'settings.email_verify_btn': 'Verificar',
+ 'settings.email_verifying': 'Verificando…',
+ 'settings.email_verified': 'Endereço de e-mail alterado com sucesso.',
+ 'settings.notif_global_disable': 'Desativar todas as notificações',
+ 'settings.notif_global_hint': 'Quando ativado, nenhuma notificação é criada para nenhum grupo.',
'settings.defaults': 'Padrões',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
@@ -466,10 +480,11 @@ export default {
+ 'passa pelo hub.',
'members.pair_btn': 'Parear',
'members.pair_success': 'Este navegador agora está pareado com o node.',
- 'members.invite_code_ready': 'Código de convite para {user} — envie a ele pelo meio '
- + 'que vocês costumam usar. Funciona uma única vez e nunca passa pelo hub.',
- 'members.invite_code_hint': 'A pessoa digita o código na primeira vez que abrir este '
- + 'grupo. Você não precisa estar on-line nesse momento.',
+ 'members.invite_code_ready': 'Código de convite para {user}:',
+ 'members.invite_email_sent': 'Um e-mail com o código foi enviado a este membro.',
+ 'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.',
+ 'members.invite_code_hint': 'Você também pode compartilhar este código por outro canal (ex: SMS). '
+ + 'O código funciona uma única vez e nunca passa pelo hub.',
'transfers.title': 'Transferências',
'transfers.clear': 'Limpar as concluídas',
'transfers.cancel': 'Cancelar',
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 105ad43..8dff38f 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
@@ -40,9 +40,18 @@ export default {
'register.loading': '正在创建账户…',
'register.has_account': '已经有账户了?',
'register.login_link': '登录',
- 'register.success_title': '账户已创建',
- 'register.success_msg': '您现在可以使用您的凭据登录了。',
+ 'register.success_title': '验证您的邮箱',
+ 'register.success_msg': '验证码已发送至您的邮箱。请在下方输入以激活账户。',
'register.go_login': '前往登录',
+ 'register.code_placeholder': '验证码',
+ 'register.verify_btn': '验证',
+ 'register.verifying': '验证中…',
+ 'register.verified_title': '邮箱已验证',
+ 'register.verified_msg': '您的账户已激活。现在可以登录了。',
+ 'register.resend': '重新发送验证码',
+ 'register.resend_sent': '新验证码已发送。',
+ 'login.pending_verification': '您的邮箱尚未验证。请查看收件箱中的验证码。',
+ 'login.verify_link': '验证邮箱',
'register.err_mismatch': '两次输入的密码不一致',
'register.err_min_len': {
other: '请至少使用 {n} 个字符',
@@ -253,10 +262,15 @@ export default {
'settings.profile': '个人资料',
'settings.username': '用户名',
'settings.email': 'Email',
- 'settings.email_save': 'Save',
- 'settings.email_saved': 'Email updated',
- 'settings.notif_global_disable': 'Disable all notifications',
- 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.email_save': '保存',
+ 'settings.email_saved': '邮箱已更新',
+ 'settings.email_code_sent': '验证码已发送至您的新邮箱地址。',
+ 'settings.email_code_placeholder': '验证码',
+ 'settings.email_verify_btn': '验证',
+ 'settings.email_verifying': '验证中…',
+ 'settings.email_verified': '邮箱地址修改成功。',
+ 'settings.notif_global_disable': '关闭所有通知',
+ 'settings.notif_global_hint': '启用后,所有群组都不会创建通知。',
'settings.defaults': '默认值',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
@@ -447,9 +461,11 @@ export default {
+ '该配对码绝不会经过 hub。',
'members.pair_btn': '配对',
'members.pair_success': '此浏览器现已与该 node 配对。',
- 'members.invite_code_ready': '给 {user} 的邀请码——用您平时联系的方式发给对方。'
- + '它只能使用一次,并且绝不会经过 hub。',
- 'members.invite_code_hint': '对方第一次打开此群组时输入即可。那时您不需要在线。',
+ 'members.invite_code_ready': '给 {user} 的邀请码:',
+ 'members.invite_email_sent': '包含验证码的邮件已发送给该成员。',
+ 'members.invite_email_failed': '无法发送邮件——请手动分享验证码。',
+ 'members.invite_code_hint': '您也可以通过其他渠道(如短信)分享此验证码。'
+ + '验证码仅可使用一次,且绝不会经过 hub。',
'transfers.title': '传输',
'transfers.clear': '清除已完成',
'transfers.cancel': '取消',
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 }) {
<div class="settings-row">
<span class="settings-label">${t('settings.email')}</span>
${emailEditing
- ? html`<span style="display:flex;gap:8px;align-items:center">
+ ? html`<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="email" value=${emailDraft}
onInput=${e => 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" />
- <button class="admin-btn" onClick=${saveEmail}
- disabled=${emailSaving}>${t('settings.email_save')}</button>
- <button class="btn-secondary" onClick=${() => {
- setEmailEditing(false); setEmailDraft(email);
- }}>${t('settings.cancel')}</button>
+ ${!emailVerifyPending && html`
+ <button class="admin-btn" onClick=${saveEmail}
+ disabled=${emailSaving}>${t('settings.email_save')}</button>
+ <button class="btn-secondary" onClick=${() => {
+ setEmailEditing(false); setEmailDraft(email);
+ }}>${t('settings.cancel')}</button>
+ `}
</span>`
: html`<span style="display:flex;gap:8px;align-items:center">
<span class="settings-value">${email || '—'}</span>
@@ -128,6 +160,23 @@ export function ProfilePage({ user, onLogout }) {
</span>`
}
</div>
+ ${emailVerifyPending && html`
+ <div style="display:flex;gap:8px;align-items:center;margin-top:8px">
+ <input type="text" placeholder="${t('settings.email_code_placeholder')}"
+ value=${emailCode} onInput=${e => 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" />
+ <button class="admin-btn" onClick=${verifyEmailChange}
+ disabled=${emailVerifying}>
+ ${emailVerifying ? t('settings.email_verifying') : t('settings.email_verify_btn')}
+ </button>
+ <button class="btn-secondary" onClick=${() => {
+ setEmailVerifyPending(false); setEmailEditing(false);
+ setEmailDraft(email); setEmailCode(''); setEmailStatus('');
+ }}>${t('settings.cancel')}</button>
+ </div>
+ `}
${emailStatus && html`<p class="settings-hint" style="margin-top:4px">${emailStatus}</p>`}
</div>
diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
index c387100..6fa62a4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
+++ b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.db.models import Group, GroupMember, IPLog
+from meshbay_hub.db.models import EmailVerification, Group, GroupMember, IPLog, User
log = logging.getLogger(__name__)
@@ -22,6 +22,26 @@ async def purge_old_ip_logs(db: AsyncSession, retention_days: int = RETENTION_DA
return result.rowcount
+PENDING_USER_EXPIRY_DAYS = 7
+
+
+async def purge_expired_verifications(db: AsyncSession) -> int:
+ now = datetime.now(timezone.utc)
+ result = await db.execute(
+ delete(EmailVerification).where(EmailVerification.expires_at < now))
+ await db.commit()
+ return result.rowcount
+
+
+async def purge_stale_pending_users(db: AsyncSession,
+ expiry_days: int = PENDING_USER_EXPIRY_DAYS) -> int:
+ cutoff = datetime.now(timezone.utc) - timedelta(days=expiry_days)
+ result = await db.execute(
+ delete(User).where(User.status == "pending", User.created_at < cutoff))
+ await db.commit()
+ return result.rowcount
+
+
async def cleanup_loop(get_session):
"""Run cleanup once at startup, then every 24 hours."""
try:
@@ -31,6 +51,12 @@ async def cleanup_loop(get_session):
deleted = await purge_old_ip_logs(db)
if deleted:
log.info("Purged %d IP log entries older than %d days", deleted, RETENTION_DAYS)
+ expired = await purge_expired_verifications(db)
+ if expired:
+ log.info("Purged %d expired email verifications", expired)
+ stale = await purge_stale_pending_users(db)
+ if stale:
+ log.info("Purged %d stale pending users", stale)
except asyncio.CancelledError:
raise
except Exception as e:
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py
index 4593f86..bf96e3d 100644
--- a/packages/meshbay-hub/tests/conftest.py
+++ b/packages/meshbay-hub/tests/conftest.py
@@ -4,6 +4,7 @@ import os
import pytest
import pytest_asyncio
from pathlib import Path
+from unittest.mock import AsyncMock
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
@@ -84,3 +85,14 @@ async def db_session(app):
from meshbay_hub.db.engine import get_session_factory
async with get_session_factory()() as session:
yield session
+
+
+@pytest.fixture(autouse=True)
+def _skip_email_verification(monkeypatch):
+ """Skip email verification in tests — users are active immediately."""
+ async def _noop(db, user, email, eh):
+ pass
+
+ monkeypatch.setattr(
+ "meshbay_hub.api.users._create_and_send_verification", _noop)
+ monkeypatch.setattr("meshbay_hub.mail._send", lambda msg: True)