Add password change functionality to user settings

Implemented a secure password change endpoint in the backend with validation. Enhanced the frontend to include a modal for updating the user's password, ensuring real-time input validation and user feedback.
This commit is contained in:
2026-02-03 20:24:20 +01:00
parent bf37850e79
commit 7036f29481
2 changed files with 215 additions and 6 deletions

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import User 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"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -31,3 +31,22 @@ def me(req: Request, db: Session = Depends(get_db)):
raise HTTPException(status_code=401, detail="not logged in") raise HTTPException(status_code=401, detail="not logged in")
return {"id": user.id, "email": user.email, "role": user.role} return {"id": user.id, "email": user.email, "role": user.role}
@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}

View File

@@ -201,6 +201,15 @@ export default function App() {
const [helpOpen, setHelpOpen] = useState(false); 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 load = async () => {
const m = await api("/auth/me"); const m = await api("/auth/me");
setMe(m); setMe(m);
@@ -211,6 +220,18 @@ export default function App() {
if (gs[0] && !gameId) setGameId(gs[0].id); 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 // Google Fonts
useEffect(() => { useEffect(() => {
if (document.getElementById("hp-fonts")) return; if (document.getElementById("hp-fonts")) return;
@@ -330,6 +351,52 @@ export default function App() {
setSheet(null); 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 newGame = async () => {
const g = await api("/games", { const g = await api("/games", {
method: "POST", method: "POST",
@@ -568,10 +635,41 @@ export default function App() {
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.role}</div> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.role}</div>
</div> </div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8, alignItems: "center" }} data-user-menu>
<button onClick={doLogout} style={styles.secondaryBtn}> <div style={{ position: "relative" }}>
<button
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title="User Menü"
>
<span style={{ fontSize: 16, lineHeight: 1 }}>👤</span>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", maxWidth: 170 }}>
{me.email}
</span>
<span style={{ opacity: 0.8 }}></span>
</button>
{userMenuOpen && (
<div style={styles.userDropdown}>
<button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen
</button>
<div style={styles.userDropdownDivider} />
<button
onClick={() => {
setUserMenuOpen(false);
doLogout();
}}
style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
>
Logout Logout
</button> </button>
</div>
)}
</div>
<button onClick={newGame} style={styles.primaryBtn}> <button onClick={newGame} style={styles.primaryBtn}>
+ Neues Spiel + Neues Spiel
</button> </button>
@@ -711,6 +809,52 @@ export default function App() {
</div> </div>
)} )}
{pwOpen && (
<div style={styles.modalOverlay} onMouseDown={closePwModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Passwort setzen</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
<input
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="Neues Passwort"
type="password"
style={styles.input}
autoFocus
/>
<input
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Neues Passwort wiederholen"
type="password"
style={styles.input}
/>
{pwMsg && <div style={{ opacity: 0.92, color: stylesTokens.textMain }}>{pwMsg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}>
Abbrechen
</button>
<button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}>
{pwSaving ? "Speichern..." : "Speichern"}
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Mindestens 8 Zeichen empfohlen.
</div>
</div>
</div>
</div>
)}
{/* Chip Popup */} {/* Chip Popup */}
{chipOpen && ( {chipOpen && (
<div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}> <div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}>
@@ -1262,4 +1406,50 @@ const styles = {
cursor: "pointer", cursor: "pointer",
minWidth: 64, 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)",
},
}; };