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:
@@ -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}
|
||||||
@@ -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" }}>
|
||||||
Logout
|
<button
|
||||||
</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
|
||||||
|
</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)",
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user