diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index f6fdb0d..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339} \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index b6e5d8a..76c1c2c 100644 --- a/backend/database.py +++ b/backend/database.py @@ -488,7 +488,8 @@ def _migrate(conn_factory): # WebCal: Kalender-Abo-Token ("users", "calendar_token", "TEXT"), # User-Profil-Felder - ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "verification_token", "TEXT"), ("users", "bio", "TEXT"), ("users", "wohnort", "TEXT"), ("users", "erfahrung", "TEXT"), diff --git a/backend/routes/auth.py b/backend/routes/auth.py index fb30584..7661c80 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -6,6 +6,7 @@ import string from typing import Optional from fastapi import APIRouter, HTTPException, Request, Response, Depends +from fastapi.responses import RedirectResponse from pydantic import BaseModel, EmailStr from database import db from auth import ( @@ -16,7 +17,28 @@ from username_blocklist import is_username_blocked from ratelimit import check as rl_check router = APIRouter() -COOKIE_NAME = "by_token" +COOKIE_NAME = "by_token" +_APP_URL = os.getenv("APP_URL", "https://banyaro.app") +_SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS")) + + +def _send_verification_email(email: str, name: str, token: str): + if not _SMTP_READY: + return + from routes.outreach import _send_smtp + subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" + body = ( + f"Hallo {name},\n\n" + "willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n" + f"{_APP_URL}/api/auth/verify-email/{token}\n\n" + "Der Link ist 7 Tage gültig.\n\n" + "Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n" + "Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app" + ) + try: + _send_smtp(email, subject, body, "support") + except Exception: + pass # Nicht blockieren wenn SMTP fehlschlägt class LoginRequest(BaseModel): @@ -64,13 +86,13 @@ async def register(data: RegisterRequest, response: Response, request: Request): ).fetchone(): raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") code = _gen_referral_code() + verify_token = secrets.token_urlsafe(32) try: conn.execute( - "INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)", - (data.email, hash_password(data.password), name, code) + "INSERT INTO users (email, pw_hash, name, referral_code, verification_token) VALUES (?,?,?,?,?)", + (data.email, hash_password(data.password), name, code, verify_token) ) except Exception: - # Fallback falls UNIQUE-Index greift (Race Condition) raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") user = conn.execute( "SELECT id, rolle FROM users WHERE email=?", (data.email,) @@ -116,7 +138,8 @@ async def register(data: RegisterRequest, response: Response, request: Request): token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) - return {"token": token, "name": name} + _send_verification_email(data.email, name, verify_token) + return {"token": token, "name": name, "email_verified": 0} @router.post("/login") @@ -206,3 +229,37 @@ async def me(user=Depends(get_current_user)): data = dict(row) data["is_premium"] = bool(data["is_premium"]) return data + + +@router.get("/verify-email/{token}") +async def verify_email(token: str): + with db() as conn: + row = conn.execute( + "SELECT id, email_verified FROM users WHERE verification_token=?", (token,) + ).fetchone() + if not row: + return RedirectResponse(f"{_APP_URL}/#settings?verified=error", status_code=302) + conn.execute( + "UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?", + (row["id"],) + ) + return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) + + +@router.post("/resend-verification") +async def resend_verification(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) + ).fetchone() + if not row: + raise HTTPException(404) + if row["email_verified"]: + return {"ok": True, "already_verified": True} + token = secrets.token_urlsafe(32) + with db() as conn: + conn.execute( + "UPDATE users SET verification_token=? WHERE id=?", (token, user["id"]) + ) + _send_verification_email(row["email"], row["name"], token) + return {"ok": True} diff --git a/backend/static/index.html b/backend/static/index.html index 5277ae8..21afd73 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -108,6 +108,26 @@ border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"> + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3b3a388..6937188 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '551'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '552'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -45,7 +45,7 @@ const App = (() => { poison: { title: 'Giftköder-Alarm', module: null }, walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true }, sitting: { title: 'Sitting', module: null, requiresAuth: true }, - forum: { title: 'Forum', module: null, requiresAuth: true }, + forum: { title: 'Forum', module: null }, wiki: { title: 'Wiki', module: null }, knigge: { title: 'Knigge', module: null }, movies: { title: 'Filme', module: null }, @@ -473,6 +473,7 @@ const App = (() => { navigate('onboarding'); } + _showVerifyBanner(); _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); @@ -551,6 +552,28 @@ const App = (() => { navigate('welcome', false); } + function _showVerifyBanner() { + const banner = document.getElementById('verify-banner'); + if (!banner) return; + if (!state.user || state.user.email_verified) { + banner.style.display = 'none'; + return; + } + const dismissed = sessionStorage.getItem('by_verify_dismissed'); + if (dismissed) return; + banner.style.display = 'flex'; + + document.getElementById('verify-resend-btn')?.addEventListener('click', async () => { + await API.post('/auth/resend-verification', {}); + UI.toast.success('Bestätigungs-Mail erneut gesendet.'); + }, { once: true }); + + document.getElementById('verify-banner-close')?.addEventListener('click', () => { + banner.style.display = 'none'; + sessionStorage.setItem('by_verify_dismissed', '1'); + }, { once: true }); + } + function _updateHeaderUserBtn(loggedIn) { const btn = document.getElementById('header-user-btn'); const icon = document.getElementById('header-user-icon'); @@ -800,6 +823,18 @@ const App = (() => { hashParams[k] = isNaN(v) ? v : Number(v); }); } + + // E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token} + if (hashParams.verified === '1' || hashParams.verified === 1) { + if (state.user) state.user.email_verified = 1; + document.getElementById('verify-banner')?.style?.setProperty('display', 'none'); + UI.toast.success('E-Mail-Adresse erfolgreich bestätigt!'); + history.replaceState(null, '', '/'); + } else if (hashParams.verified === 'error') { + UI.toast.error('Ungültiger oder abgelaufener Bestätigungs-Link.'); + history.replaceState(null, '', '/'); + } + const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. navigate(state.user ? startPage : 'welcome', false, hashParams); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8f45cf6..a0f3a9a 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -138,7 +138,15 @@ window.Page_settings = (() => { style="display:none">
${_esc(u.name)}
-
${_esc(u.email)}
+
+ ${_esc(u.email)} + ${u.email_verified + ? `` + : `Nicht bestätigt`} +
${u.is_premium ? ` @@ -480,6 +488,12 @@ window.Page_settings = (() => { }); // Avatar-Hover-Overlay + // E-Mail-Verifikation: Chip → erneut senden + document.getElementById('settings-verify-chip')?.addEventListener('click', async () => { + await API.post('/auth/resend-verification', {}); + UI.toast.success('Bestätigungs-Mail gesendet — bitte prüfe dein Postfach.'); + }); + const avatarBtn = document.getElementById('settings-avatar-btn'); const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay'); if (avatarBtn && avatarOverlay) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 4cd74e1..9b0bb55 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v574'; +const CACHE_VERSION = 'by-v575'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache