diff --git a/backend/app/mailer.py b/backend/app/mailer.py new file mode 100644 index 0000000..59104ec --- /dev/null +++ b/backend/app/mailer.py @@ -0,0 +1,63 @@ +import html +import smtplib +import ssl +from email.message import EmailMessage +from email.utils import formataddr + +from .models import AppSettings, User + + +def _settings_ready(settings: AppSettings | None) -> bool: + return bool(settings and settings.smtp_host and settings.smtp_from_email) + + +def send_html_email(settings: AppSettings, recipient: str, subject: str, body_html: str, body_text: str): + if not _settings_ready(settings): + raise ValueError("SMTP settings are incomplete") + + message = EmailMessage() + message["Subject"] = subject + message["From"] = formataddr((settings.smtp_from_name or "Cluedo HP", settings.smtp_from_email)) + message["To"] = recipient + message.set_content(body_text) + message.add_alternative(body_html, subtype="html") + + context = ssl.create_default_context() + with smtplib.SMTP(settings.smtp_host, settings.smtp_port or 587, timeout=20) as server: + server.ehlo() + if settings.smtp_use_tls: + server.starttls(context=context) + server.ehlo() + if settings.smtp_username: + server.login(settings.smtp_username, settings.smtp_password) + server.send_message(message) + + +def send_user_invite(settings: AppSettings, user: User, invite_url: str): + name = html.escape(user.display_name or user.email) + safe_url = html.escape(invite_url, quote=True) + from_name = html.escape(settings.smtp_from_name or "Cluedo HP") + body_html = f""" + + +
+
+
+
{from_name}
+
Eine Einladung wartet
+
+
+
Hallo {name},
+

du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.

+

Richte über den folgenden Button dein persönliches Passwort ein:

+

Einladung annehmen

+

Der Link ist 48 Stunden gültig und kann nur einmal verwendet werden.

+

{safe_url}

+
+
Deine Notizen bleiben privat.
+
+
+ + """ + body_text = f"Hallo {user.display_name or user.email},\n\nbitte richte dein Passwort ein: {invite_url}\n\nDer Link ist 48 Stunden gültig." + send_html_email(settings, user.email, "Deine Einladung zum Zauber-Detektiv-Notizbogen", body_html, body_text) diff --git a/backend/app/main.py b/backend/app/main.py index b9d2d55..36ebdab 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from .routes.auth import router as auth_router from .routes.admin import router as admin_router from .routes.games import router as games_router from .routes.setup import router as setup_router +from .routes.invites import router as invites_router app = FastAPI(title="Cluedo Sheet") @@ -31,6 +32,7 @@ app.include_router(auth_router) app.include_router(admin_router) app.include_router(games_router) app.include_router(setup_router) +app.include_router(invites_router) def _rand_join_code(n: int = 6) -> str: diff --git a/backend/app/models.py b/backend/app/models.py index edb7730..886ff52 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -41,6 +41,31 @@ class User(Base): display_name: Mapped[str] = mapped_column(String, default="") +class AppSettings(Base): + __tablename__ = "app_settings" + + id: Mapped[str] = mapped_column(String, primary_key=True, default="default") + smtp_host: Mapped[str] = mapped_column(String, default="") + smtp_port: Mapped[int] = mapped_column(Integer, default=587) + smtp_username: Mapped[str] = mapped_column(String, default="") + smtp_password: Mapped[str] = mapped_column(String, default="") + smtp_from_email: Mapped[str] = mapped_column(String, default="") + smtp_from_name: Mapped[str] = mapped_column(String, default="Cluedo HP") + smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True) + app_base_url: Mapped[str] = mapped_column(String, default="http://localhost:8081") + + +class InviteToken(Base): + __tablename__ = "invite_tokens" + __table_args__ = (UniqueConstraint("token_hash", name="uq_invite_token_hash"),) + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) + token_hash: Mapped[str] = mapped_column(String, index=True) + expires_at: Mapped[str] = mapped_column(DateTime(timezone=True)) + used_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True) + + class Game(Base): __tablename__ = "games" id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 47e74da..a9da507 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -1,7 +1,12 @@ +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from ..db import get_db -from ..models import User, Role +from ..mailer import send_html_email, send_user_invite +from ..models import AppSettings, InviteToken, User, Role from ..security import hash_password, get_session_user_id router = APIRouter(prefix="/admin", tags=["admin"]) @@ -37,18 +42,47 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)): password = data.get("password") or "" display_name = (data.get("display_name") or "").strip() - if not email or not password: - raise HTTPException(400, "email/password required") + if not email or not display_name: + raise HTTPException(400, "name/email required") if db.query(User).filter(User.email == email).first(): raise HTTPException(409, "email exists") - role = data.get("role") or Role.user.value + role = data.get("role") + if not role: + raise HTTPException(400, "role required") if role not in (Role.admin.value, Role.user.value): raise HTTPException(400, "invalid role") + settings = db.query(AppSettings).filter(AppSettings.id == "default").first() or AppSettings(id="default") + invite_sent = not password + raw_token = None + + if invite_sent: + if not settings.smtp_host or not settings.smtp_from_email: + raise HTTPException(400, "configure SMTP settings before sending invites") + raw_token = secrets.token_urlsafe(32) + password = secrets.token_urlsafe(32) + u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name) - db.add(u); db.commit() - return {"ok": True, "id": u.id} + db.add(u) + db.flush() + + if invite_sent: + invite = InviteToken( + user_id=u.id, + token_hash=hashlib.sha256(raw_token.encode()).hexdigest(), + expires_at=datetime.now(timezone.utc) + timedelta(hours=48), + ) + db.add(invite) + invite_url = f"{(settings.app_base_url or 'http://localhost:8081').rstrip('/')}/invite/{raw_token}" + try: + send_user_invite(settings, u, invite_url) + except Exception as exc: + db.rollback() + raise HTTPException(502, f"invite email could not be sent: {exc}") + + db.commit() + return {"ok": True, "id": u.id, "invite_sent": invite_sent} @router.delete("/users/{user_id}") def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)): @@ -106,3 +140,74 @@ def update_user(req: Request, user_id: str, data: dict, db: Session = Depends(ge db.add(user) db.commit() return {"ok": True} + + +def get_settings(db: Session) -> AppSettings: + settings = db.query(AppSettings).filter(AppSettings.id == "default").first() + if not settings: + settings = AppSettings(id="default") + db.add(settings) + db.commit() + db.refresh(settings) + return settings + + +@router.get("/settings/smtp") +def read_smtp_settings(req: Request, db: Session = Depends(get_db)): + require_admin(req, db) + settings = get_settings(db) + return { + "smtp_host": settings.smtp_host, + "smtp_port": settings.smtp_port, + "smtp_username": settings.smtp_username, + "smtp_password_configured": bool(settings.smtp_password), + "smtp_from_email": settings.smtp_from_email, + "smtp_from_name": settings.smtp_from_name, + "smtp_use_tls": settings.smtp_use_tls, + "app_base_url": settings.app_base_url, + } + + +@router.patch("/settings/smtp") +def update_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)): + require_admin(req, db) + settings = get_settings(db) + + settings.smtp_host = (data.get("smtp_host") or "").strip() + try: + settings.smtp_port = int(data.get("smtp_port") or 587) + except (TypeError, ValueError): + raise HTTPException(400, "SMTP port must be a number") + settings.smtp_username = (data.get("smtp_username") or "").strip() + settings.smtp_from_email = (data.get("smtp_from_email") or "").strip() + settings.smtp_from_name = (data.get("smtp_from_name") or "Cluedo HP").strip() + settings.smtp_use_tls = bool(data.get("smtp_use_tls", True)) + settings.app_base_url = (data.get("app_base_url") or "http://localhost:8081").strip().rstrip("/") + if "smtp_password" in data and data.get("smtp_password"): + settings.smtp_password = data["smtp_password"] + + if settings.smtp_host and not settings.smtp_from_email: + raise HTTPException(400, "from email required when SMTP is configured") + db.add(settings) + db.commit() + return {"ok": True} + + +@router.post("/settings/smtp/test") +def test_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)): + require_admin(req, db) + settings = get_settings(db) + recipient = (data.get("recipient") or "").strip() + if not recipient: + raise HTTPException(400, "recipient required") + try: + send_html_email( + settings, + recipient, + "SMTP-Test – Cluedo HP", + "

SMTP funktioniert

Der Einladungsversand ist bereit.

", + "SMTP funktioniert. Der Einladungsversand ist bereit.", + ) + except Exception as exc: + raise HTTPException(502, f"SMTP test failed: {exc}") + return {"ok": True} diff --git a/backend/app/routes/invites.py b/backend/app/routes/invites.py new file mode 100644 index 0000000..f811658 --- /dev/null +++ b/backend/app/routes/invites.py @@ -0,0 +1,47 @@ +import hashlib +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from sqlalchemy.orm import Session + +from ..db import get_db +from ..models import InviteToken, User +from ..security import hash_password, make_session_value, set_session + +router = APIRouter(prefix="/auth/invite", tags=["invites"]) + + +def get_invite(token: str, db: Session): + token_hash = hashlib.sha256(token.encode()).hexdigest() + invite = db.query(InviteToken).filter(InviteToken.token_hash == token_hash).first() + expires_at = invite.expires_at if invite else None + if expires_at and expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if not invite or invite.used_at or expires_at <= datetime.now(timezone.utc): + raise HTTPException(410, "invite is invalid or expired") + user = db.query(User).filter(User.id == invite.user_id, User.disabled == False).first() + if not user: + raise HTTPException(410, "invite is invalid or expired") + return invite, user + + +@router.get("/{token}") +def invite_info(token: str, db: Session = Depends(get_db)): + _invite, user = get_invite(token, db) + return {"valid": True, "display_name": user.display_name, "email": user.email, "role": user.role} + + +@router.post("/{token}") +def accept_invite(token: str, data: dict, resp: Response, db: Session = Depends(get_db)): + invite, user = get_invite(token, db) + password = data.get("password") or "" + if len(password) < 8: + raise HTTPException(400, "password too short (min 8)") + + user.password_hash = hash_password(password) + invite.used_at = datetime.now(timezone.utc) + db.add(user) + db.add(invite) + db.commit() + set_session(resp, make_session_value(user.id)) + return {"ok": True} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c5d18b1..71c0c3b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,6 +25,8 @@ import WinnerCard from "./components/WinnerCard"; import WinnerBadge from "./components/WinnerBadge"; import NewGameModal from "./components/NewGameModal"; import StatsModal from "./components/StatsModal"; +import AdminSettingsModal from "./components/AdminSettingsModal"; +import InvitePage from "./components/InvitePage"; export default function App() { useHpGlobalStyles(); @@ -62,6 +64,7 @@ export default function App() { const [chipEntry, setChipEntry] = useState(null); const [userMenuOpen, setUserMenuOpen] = useState(false); const [adminOpen, setAdminOpen] = useState(false); + const [adminSettingsOpen, setAdminSettingsOpen] = useState(false); const [pwOpen, setPwOpen] = useState(false); const [pw1, setPw1] = useState(""); @@ -94,6 +97,7 @@ export default function App() { const [celebrateOpen, setCelebrateOpen] = useState(false); const [celebrateName, setCelebrateName] = useState(""); const [startCelebrateOpen, setStartCelebrateOpen] = useState(false); + const inviteToken = window.location.pathname.startsWith("/invite/") ? decodeURIComponent(window.location.pathname.slice("/invite/".length)) : ""; // baseline per game: beim ersten Meta-Load NICHT feiern const winnerBaselineRef = useRef(false); @@ -628,6 +632,10 @@ export default function App() { }; // ===== Login page ===== + if (inviteToken) { + return ; + } + if (!me) { return ( { + setAdminSettingsOpen(true); + setUserMenuOpen(false); + }} doLogout={doLogout} onOpenNewGame={() => setNewGameOpen(true)} /> @@ -701,6 +713,9 @@ export default function App() { {me.role === "admin" && ( setAdminOpen(false)} currentUserId={me.id} /> )} + {me.role === "admin" && ( + setAdminSettingsOpen(false)} /> + )} { @@ -38,6 +39,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre setEditingUser(null); setForm({ ...emptyForm }); setMsg(""); + setNotice(""); setEditorOpen(true); }; @@ -51,6 +53,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre disabled: !!user.disabled, }); setMsg(""); + setNotice(""); setEditorOpen(true); }; @@ -63,7 +66,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre const saveUser = async () => { setMsg(""); if (!form.email.trim()) return setMsg("❌ E-Mail ist erforderlich."); - if (!editingUser && form.password.length < 8) return setMsg("❌ Passwort muss mindestens 8 Zeichen haben."); + if (!editingUser && form.password && form.password.length < 8) return setMsg("❌ Passwort muss mindestens 8 Zeichen haben."); if (editingUser && form.password && form.password.length < 8) return setMsg("❌ Neues Passwort muss mindestens 8 Zeichen haben."); setSaving(true); @@ -76,11 +79,12 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre }; if (form.password) payload.password = form.password; - await api(editingUser ? `/admin/users/${editingUser.id}` : "/admin/users", { + const result = await api(editingUser ? `/admin/users/${editingUser.id}` : "/admin/users", { method: editingUser ? "PATCH" : "POST", body: JSON.stringify(payload), }); await loadUsers(); + setNotice(result.invite_sent ? "✅ Einladung wurde per E-Mail versendet." : "✅ User gespeichert."); closeEditor(); } catch (e) { setMsg("❌ " + (e?.message || "Speichern fehlgeschlagen.")); @@ -118,6 +122,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
Vorhandene User ({users.length})
+ {notice &&
{notice}
}
{users.map((user) => ( @@ -150,10 +155,11 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
- + {editingUser && } {msg &&
{msg}
} + {!editingUser &&
Ohne Passwort wird automatisch eine einmalige Einladung per SMTP verschickt.
}
diff --git a/frontend/src/components/AdminSettingsModal.jsx b/frontend/src/components/AdminSettingsModal.jsx new file mode 100644 index 0000000..47bd93f --- /dev/null +++ b/frontend/src/components/AdminSettingsModal.jsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { api } from "../api/client"; +import { styles } from "../styles/styles"; +import { stylesTokens } from "../styles/theme"; + +const initial = { + smtp_host: "", smtp_port: 587, smtp_username: "", smtp_password: "", + smtp_from_email: "", smtp_from_name: "Cluedo HP", smtp_use_tls: true, + app_base_url: window.location.origin, +}; + +export default function AdminSettingsModal({ open, onClose }) { + const [form, setForm] = useState(initial); + const [configured, setConfigured] = useState(false); + const [recipient, setRecipient] = useState(""); + const [message, setMessage] = useState(""); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + + useEffect(() => { + if (!open) return; + setMessage(""); + api("/admin/settings/smtp").then((data) => { + setForm({ ...initial, ...data, smtp_password: "" }); + setConfigured(!!data.smtp_password_configured); + }).catch((error) => setMessage("❌ " + (error?.message || "Laden fehlgeschlagen."))); + }, [open]); + + const setField = (key, value) => setForm((current) => ({ ...current, [key]: value })); + const save = async () => { + setSaving(true); setMessage(""); + try { + await api("/admin/settings/smtp", { method: "PATCH", body: JSON.stringify(form) }); + setConfigured(!!form.smtp_password || configured); + setMessage("✅ SMTP-Einstellungen gespeichert."); + } catch (error) { setMessage("❌ " + (error?.message || "Speichern fehlgeschlagen.")); } + finally { setSaving(false); } + }; + const test = async () => { + if (!recipient.trim()) return setMessage("❌ Bitte eine Test-E-Mail angeben."); + setTesting(true); setMessage(""); + try { + await api("/admin/settings/smtp/test", { method: "POST", body: JSON.stringify({ recipient }) }); + setMessage("✅ Test-E-Mail wurde versendet."); + } catch (error) { setMessage("❌ " + (error?.message || "Test fehlgeschlagen.")); } + finally { setTesting(false); } + }; + + if (!open) return null; + return createPortal( +
+
e.stopPropagation()}> +
+
Admin Settings
SMTP und Einladungen konfigurieren
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ {message &&
{message}
} + +
+
+
, document.body + ); +} diff --git a/frontend/src/components/HelpModal.jsx b/frontend/src/components/HelpModal.jsx index 24bf3e7..3dd8497 100644 --- a/frontend/src/components/HelpModal.jsx +++ b/frontend/src/components/HelpModal.jsx @@ -169,6 +169,10 @@ export default function HelpModal({ open, onClose }) { 🛡️
Admin Dashboard = User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren
+
+ +
Admin Settings = SMTP konfigurieren und Einladungen ohne vorgegebenes Passwort versenden
+
diff --git a/frontend/src/components/InvitePage.jsx b/frontend/src/components/InvitePage.jsx new file mode 100644 index 0000000..518b0ff --- /dev/null +++ b/frontend/src/components/InvitePage.jsx @@ -0,0 +1,40 @@ +import React, { useEffect, useState } from "react"; +import { api } from "../api/client"; +import { styles } from "../styles/styles"; +import { stylesTokens } from "../styles/theme"; + +export default function InvitePage({ token }) { + const [info, setInfo] = useState(null); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [message, setMessage] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { api(`/auth/invite/${encodeURIComponent(token)}`).then(setInfo).catch((e) => setMessage("❌ " + (e?.message || "Diese Einladung ist nicht mehr gültig."))); }, [token]); + const submit = async (event) => { + event.preventDefault(); setMessage(""); + if (password.length < 8) return setMessage("❌ Das Passwort muss mindestens 8 Zeichen haben."); + if (password !== confirm) return setMessage("❌ Die Passwörter stimmen nicht überein."); + setSaving(true); + try { await api(`/auth/invite/${encodeURIComponent(token)}`, { method: "POST", body: JSON.stringify({ password }) }); setMessage("✅ Passwort gesetzt. Du wirst weitergeleitet …"); setTimeout(() => { window.location.href = "/"; }, 700); } + catch (e) { setMessage("❌ " + (e?.message || "Einladung konnte nicht aktiviert werden.")); setSaving(false); } + }; + + return
+ ; +} diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index 032d624..fafaf79 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -10,6 +10,7 @@ export default function TopBar({ openDesignModal, openStatsModal, openAdminPanel, + openAdminSettings, doLogout, onOpenNewGame, }) { @@ -75,6 +76,15 @@ export default function TopBar({ > Admin Dashboard + )} diff --git a/frontend/src/styles/hooks/useHpGlobalStyles.js b/frontend/src/styles/hooks/useHpGlobalStyles.js index aff555e..adb0622 100644 --- a/frontend/src/styles/hooks/useHpGlobalStyles.js +++ b/frontend/src/styles/hooks/useHpGlobalStyles.js @@ -113,10 +113,17 @@ export function useHpGlobalStyles() { .hp-admin-check { display: flex; align-items: center; gap: 8px; color: var(--hp-textDim); font-size: 13px; } .hp-admin-check input { width: 18px; height: 18px; accent-color: var(--hp-textGold); } .hp-admin-editor-card { overflow: hidden; } + .hp-settings-grid { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 10px; } + .hp-settings-test { display: flex; align-items: end; gap: 10px; } + .hp-invite-card { animation: popIn 480ms cubic-bezier(.2,.8,.2,1); box-shadow: 0 24px 80px rgba(0,0,0,.6), 0 0 40px rgba(233,216,166,.08); } + .hp-invite-seal { width: 52px; height: 52px; margin: 0 auto 14px; display: grid; place-items: center; border: 1px solid rgba(233,216,166,.42); border-radius: 50%; color: var(--hp-textGold); font-size: 24px; box-shadow: 0 0 24px rgba(233,216,166,.12); } .hp-admin-role { padding: 4px 8px; border-radius: 999px; background: rgba(233,216,166,0.10); border: 1px solid rgba(233,216,166,0.16); font-size: 12px; } .hp-admin-status { color: #baf3c9 !important; font-size: 13px; } .hp-admin-status--disabled { color: #ffb3b3 !important; } @media (max-width: 600px) { + .hp-settings-grid { grid-template-columns: 1fr; } + .hp-settings-test { align-items: stretch; flex-direction: column; } + .hp-settings-test button { width: 100%; } .hp-shell { padding: calc(12px + env(safe-area-inset-top)) 10px calc(28px + env(safe-area-inset-bottom)) !important; } .hp-topbar { align-items: center !important; padding: 10px !important; gap: 6px !important; flex-wrap: nowrap !important; } .hp-topbar > div:first-child { min-width: 0; flex: 1; } @@ -136,6 +143,7 @@ export function useHpGlobalStyles() { .hp-admin-actions .hp-admin-action { width: 36px; min-width: 36px; min-height: 36px; padding: 0 !important; } .hp-admin-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; } .hp-admin-editor-card input, .hp-admin-editor-card select { min-height: 40px; padding: 8px 10px !important; } + .hp-admin-settings-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; overflow: auto; } .hp-admin-editor-card button { min-height: 40px; } .hp-row { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; } .hp-row button { min-height: 40px; }