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:
parent
e79290edb7
commit
b9ee67b8dd
7 changed files with 137 additions and 11 deletions
|
|
@ -1 +0,0 @@
|
||||||
{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339}
|
|
||||||
|
|
@ -488,7 +488,8 @@ def _migrate(conn_factory):
|
||||||
# WebCal: Kalender-Abo-Token
|
# WebCal: Kalender-Abo-Token
|
||||||
("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"),
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
@ -16,7 +17,28 @@ from username_blocklist import is_username_blocked
|
||||||
from ratelimit import check as rl_check
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue