Feature: E-Mail-Verifikation + Forum öffentlich lesbar + Launch-Vorbereitung

- Forum ohne requiresAuth: öffentlich lesbar, Schreiben weiter via API-Guard
- E-Mail-Verifikation: Token bei Registrierung, support@-Mail, /verify-email/{token}
- Verifikations-Banner (orange, dismissible) wenn email_verified=0
- Grüner Haken / "Nicht bestätigt"-Chip in Settings
- POST /auth/resend-verification für Chip und Banner
- DB-Migration: users.verification_token TEXT
- SW by-v575, APP_VER 552
This commit is contained in:
rene 2026-04-30 19:51:07 +02:00
parent e79290edb7
commit b9ee67b8dd
7 changed files with 137 additions and 11 deletions

View file

@ -1 +0,0 @@
{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339}

View file

@ -489,6 +489,7 @@ def _migrate(conn_factory):
("users", "calendar_token", "TEXT"), ("users", "calendar_token", "TEXT"),
# User-Profil-Felder # 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", "bio", "TEXT"),
("users", "wohnort", "TEXT"), ("users", "wohnort", "TEXT"),
("users", "erfahrung", "TEXT"), ("users", "erfahrung", "TEXT"),

View file

@ -6,6 +6,7 @@ import string
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Request, Response, Depends from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from database import db from database import db
from auth import ( from auth import (
@ -17,6 +18,27 @@ from ratelimit import check as rl_check
router = APIRouter() 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): class LoginRequest(BaseModel):
@ -64,13 +86,13 @@ async def register(data: RegisterRequest, response: Response, request: Request):
).fetchone(): ).fetchone():
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
code = _gen_referral_code() code = _gen_referral_code()
verify_token = secrets.token_urlsafe(32)
try: try:
conn.execute( conn.execute(
"INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)", "INSERT INTO users (email, pw_hash, name, referral_code, verification_token) VALUES (?,?,?,?,?)",
(data.email, hash_password(data.password), name, code) (data.email, hash_password(data.password), name, code, verify_token)
) )
except Exception: except Exception:
# Fallback falls UNIQUE-Index greift (Race Condition)
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
user = conn.execute( user = conn.execute(
"SELECT id, rolle FROM users WHERE email=?", (data.email,) "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"]) token = create_token(user["id"], user["rolle"])
_set_cookie(response, token) _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") @router.post("/login")
@ -206,3 +229,37 @@ async def me(user=Depends(get_current_user)):
data = dict(row) data = dict(row)
data["is_premium"] = bool(data["is_premium"]) data["is_premium"] = bool(data["is_premium"])
return data 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}

View file

@ -108,6 +108,26 @@
border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"></span> border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"></span>
</div> </div>
<!-- E-Mail-Verifikations-Banner -->
<div id="verify-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9998;
background:#d97706;color:#fff;font-size:0.8rem;font-weight:500;
padding:8px 16px;align-items:center;justify-content:center;gap:10px;
box-shadow:0 2px 8px rgba(0,0,0,.2)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.19V181.81ZM40,61.62l88,80.15,88-80.15Z"/>
</svg>
<span>Bitte bestätige deine E-Mail-Adresse — wir haben dir eine Mail geschickt.</span>
<button id="verify-resend-btn"
style="background:rgba(255,255,255,.2);border:none;color:#fff;padding:3px 10px;
border-radius:999px;font-size:0.75rem;cursor:pointer;font-weight:600">
Erneut senden
</button>
<button id="verify-banner-close"
style="background:none;border:none;color:#fff;opacity:.7;cursor:pointer;
font-size:1rem;line-height:1;padding:0 4px" aria-label="Schließen">✕</button>
</div>
<!-- Backdrop + Sidebar direkt im body (kein Ancestor-Stacking-Context) --> <!-- Backdrop + Sidebar direkt im body (kein Ancestor-Stacking-Context) -->
<div id="sidebar-backdrop" class="sidebar-backdrop"></div> <div id="sidebar-backdrop" class="sidebar-backdrop"></div>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
@ -45,7 +45,7 @@ const App = (() => {
poison: { title: 'Giftköder-Alarm', module: null }, poison: { title: 'Giftköder-Alarm', module: null },
walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true }, walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true },
sitting: { title: 'Sitting', 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 }, wiki: { title: 'Wiki', module: null },
knigge: { title: 'Knigge', module: null }, knigge: { title: 'Knigge', module: null },
movies: { title: 'Filme', module: null }, movies: { title: 'Filme', module: null },
@ -473,6 +473,7 @@ const App = (() => {
navigate('onboarding'); navigate('onboarding');
} }
_showVerifyBanner();
_updateNotifBadge(); _updateNotifBadge();
_updateChatBadge(); _updateChatBadge();
_checkNearbyAlerts(); _checkNearbyAlerts();
@ -551,6 +552,28 @@ const App = (() => {
navigate('welcome', false); 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) { function _updateHeaderUserBtn(loggedIn) {
const btn = document.getElementById('header-user-btn'); const btn = document.getElementById('header-user-btn');
const icon = document.getElementById('header-user-icon'); const icon = document.getElementById('header-user-icon');
@ -800,6 +823,18 @@ const App = (() => {
hashParams[k] = isNaN(v) ? v : Number(v); 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'; const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
navigate(state.user ? startPage : 'welcome', false, hashParams); navigate(state.user ? startPage : 'welcome', false, hashParams);

View file

@ -138,7 +138,15 @@ window.Page_settings = (() => {
style="display:none"> style="display:none">
<div> <div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div> <div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div> <div style="display:flex;align-items:center;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
${_esc(u.email)}
${u.email_verified
? `<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px;color:#22c55e" title="Bestätigt"><use href="/icons/phosphor.svg#check-circle"></use></svg>`
: `<span id="settings-verify-chip"
style="font-size:10px;background:#fef3c7;color:#d97706;padding:1px 7px;
border-radius:999px;cursor:pointer;white-space:nowrap"
title="E-Mail noch nicht bestätigt">Nicht bestätigt</span>`}
</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)"> <div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
${u.is_premium ${u.is_premium
? `<span class="badge badge-primary"> ? `<span class="badge badge-primary">
@ -480,6 +488,12 @@ window.Page_settings = (() => {
}); });
// Avatar-Hover-Overlay // 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 avatarBtn = document.getElementById('settings-avatar-btn');
const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay'); const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay');
if (avatarBtn && avatarOverlay) { if (avatarBtn && avatarOverlay) {

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v574'; const CACHE_VERSION = 'by-v575';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache