diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index fbd7a55..6ece26a 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy.orm import Session from ..db import get_db from ..models import User -from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id +from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id, hash_password router = APIRouter(prefix="/auth", tags=["auth"]) @@ -30,4 +30,23 @@ def me(req: Request, db: Session = Depends(get_db)): if not user: raise HTTPException(status_code=401, detail="not logged in") return {"id": user.id, "email": user.email, "role": user.role} - \ No newline at end of file + +@router.patch("/password") +def set_password(data: dict, req: Request, db: Session = Depends(get_db)): + uid = get_session_user_id(req) + if not uid: + raise HTTPException(status_code=401, detail="not logged in") + + password = data.get("password") or "" + if len(password) < 8: + raise HTTPException(status_code=400, detail="password too short (min 8)") + + user = db.query(User).filter(User.id == uid, User.disabled == False).first() + if not user: + raise HTTPException(status_code=401, detail="not logged in") + + user.password_hash = hash_password(password) + db.add(user) + db.commit() + + return {"ok": True} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 86b2737..090a6f3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -201,6 +201,15 @@ export default function App() { const [helpOpen, setHelpOpen] = useState(false); + // User dropdown + Passwort Modal + const [userMenuOpen, setUserMenuOpen] = useState(false); + + const [pwOpen, setPwOpen] = useState(false); + const [pw1, setPw1] = useState(""); + const [pw2, setPw2] = useState(""); + const [pwMsg, setPwMsg] = useState(""); + const [pwSaving, setPwSaving] = useState(false); + const load = async () => { const m = await api("/auth/me"); setMe(m); @@ -211,6 +220,18 @@ export default function App() { if (gs[0] && !gameId) setGameId(gs[0].id); }; + // Usermanager + useEffect(() => { + const onDown = (e) => { + // wenn Dropdown offen & Klick ist NICHT in einem Element mit data-user-menu + const root = e.target?.closest?.("[data-user-menu]"); + if (!root) setUserMenuOpen(false); + }; + + if (userMenuOpen) document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [userMenuOpen]); + // Google Fonts useEffect(() => { if (document.getElementById("hp-fonts")) return; @@ -330,6 +351,52 @@ export default function App() { setSheet(null); }; + // ===== Password change ===== + const openPwModal = () => { + setPwMsg(""); + setPw1(""); + setPw2(""); + setPwOpen(true); + setUserMenuOpen(false); + }; + + const closePwModal = () => { + setPwOpen(false); + setPwMsg(""); + setPw1(""); + setPw2(""); + }; + + const savePassword = async () => { + setPwMsg(""); + + if (!pw1 || pw1.length < 8) { + setPwMsg("❌ Passwort muss mindestens 8 Zeichen haben."); + return; + } + if (pw1 !== pw2) { + setPwMsg("❌ Passwörter stimmen nicht überein."); + return; + } + + setPwSaving(true); + try { + await api("/auth/password", { + method: "PATCH", + body: JSON.stringify({ password: pw1 }), + }); + + setPwMsg("✅ Passwort gespeichert."); + // Optional: nach kurzer Zeit automatisch schließen + setTimeout(() => closePwModal(), 650); + } catch (e) { + // dein api() wirft res.text() -> oft JSON Detail. Wir zeigen es einfach roh. + setPwMsg("❌ Fehler: " + (e?.message || "unknown")); + } finally { + setPwSaving(false); + } + }; + const newGame = async () => { const g = await api("/games", { method: "POST", @@ -568,10 +635,41 @@ export default function App() {
{me.role}
-
- +
+
+ + + {userMenuOpen && ( +
+ + +
+ + +
+ )} +
+ @@ -711,6 +809,52 @@ export default function App() {
)} + {pwOpen && ( +
+
e.stopPropagation()}> +
+
Passwort setzen
+ +
+ +
+ setPw1(e.target.value)} + placeholder="Neues Passwort" + type="password" + style={styles.input} + autoFocus + /> + setPw2(e.target.value)} + placeholder="Neues Passwort wiederholen" + type="password" + style={styles.input} + /> + + {pwMsg &&
{pwMsg}
} + +
+ + +
+ +
+ Hinweis: Mindestens 8 Zeichen empfohlen. +
+
+
+
+ )} + {/* Chip Popup */} {chipOpen && (
@@ -1262,4 +1406,50 @@ const styles = { cursor: "pointer", minWidth: 64, }, + + userBtn: { + display: "inline-flex", + alignItems: "center", + gap: 8, + padding: "10px 12px", + borderRadius: 12, + border: `1px solid rgba(233,216,166,0.18)`, + background: "rgba(255,255,255,0.05)", + color: stylesTokens.textMain, + fontWeight: 900, + cursor: "pointer", + boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)", + maxWidth: 260, + }, + + userDropdown: { + position: "absolute", + right: 0, + top: "calc(100% + 8px)", + minWidth: 220, + borderRadius: 14, + border: `1px solid rgba(233,216,166,0.18)`, + background: "linear-gradient(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))", + boxShadow: "0 18px 55px rgba(0,0,0,0.70)", + overflow: "hidden", + zIndex: 10000, + backdropFilter: "blur(8px)", + }, + + userDropdownItem: { + width: "100%", + textAlign: "left", + padding: "10px 12px", + border: "none", + background: "transparent", + color: stylesTokens.textMain, + fontWeight: 900, + cursor: "pointer", + }, + + userDropdownDivider: { + height: 1, + background: "rgba(233,216,166,0.12)", + }, + };