diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-31 17:19:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-31 17:19:17 +0200 |
| commit | c6fd7ea89b6e0a96eb1d81989de891b4768b1044 (patch) | |
| tree | 12e869044c80c889f83b588210cc0ac500cd7a6a /packages/meshbay-hub | |
| parent | f4c6628c8e85513d9fd110ead95682368a15a0fd (diff) | |
| download | meshbay-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')
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) |