Add user editing functionality to admin panel with improved UI and self-protection safeguards

Implemented comprehensive user editing in admin dashboard with PATCH endpoint for updating email, display name, role, password, and disabled status. Added validation to prevent admins from demoting or disabling themselves, and duplicate email detection. Refactored AdminPanel to modal-based editor with separate create/edit modes, form state management, and save/cancel actions. Enhanced UI with field labels, checkbox for account status, action
This commit is contained in:
2026-08-02 10:11:38 +02:00
parent 630a816df0
commit a532cae9bd
6 changed files with 172 additions and 183 deletions
+40 -3
View File
@@ -61,11 +61,48 @@ def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
if not u: if not u:
raise HTTPException(404, "not found") raise HTTPException(404, "not found")
if u.role == Role.admin.value:
raise HTTPException(400, "cannot delete admin user")
# soft delete # soft delete
u.disabled = True u.disabled = True
db.add(u) db.add(u)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@router.patch("/users/{user_id}")
def update_user(req: Request, user_id: str, data: dict, db: Session = Depends(get_db)):
admin = require_admin(req, db)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "not found")
email = (data.get("email") or user.email).lower().strip()
display_name = (data.get("display_name") if "display_name" in data else user.display_name or "").strip()
role = data.get("role") or user.role
password = data.get("password") or ""
if not email or "@" not in email:
raise HTTPException(400, "valid email required")
if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role")
if password and len(password) < 8:
raise HTTPException(400, "password too short (min 8)")
if admin.id == user_id and role != Role.admin.value:
raise HTTPException(400, "cannot demote yourself")
if admin.id == user_id and data.get("disabled") is True:
raise HTTPException(400, "cannot disable yourself")
duplicate = db.query(User).filter(User.email == email, User.id != user_id).first()
if duplicate:
raise HTTPException(409, "email exists")
user.email = email
user.display_name = display_name
user.role = role
if password:
user.password_hash = hash_password(password)
if "disabled" in data:
user.disabled = bool(data["disabled"])
db.add(user)
db.commit()
return {"ok": True}
+1 -1
View File
@@ -699,7 +699,7 @@ export default function App() {
/> />
{me.role === "admin" && ( {me.role === "admin" && (
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} /> <AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} currentUserId={me.id} />
)} )}
<GamePickerCard <GamePickerCard
+107 -166
View File
@@ -1,226 +1,167 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "../api/client"; import { api } from "../api/client";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { createPortal } from "react-dom";
export default function AdminPanel({ open: dashboardOpen = false, onClose }) { const emptyForm = { displayName: "", email: "", password: "", role: "user", disabled: false };
export default function AdminPanel({ open: dashboardOpen = false, onClose, currentUserId }) {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [editorOpen, setEditorOpen] = useState(false);
const [userModalOpen, setUserModalOpen] = useState(false); const [editingUser, setEditingUser] = useState(null);
const [displayName, setDisplayName] = useState(""); const [form, setForm] = useState(emptyForm);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (!dashboardOpen && !userModalOpen) return; if (!dashboardOpen && !editorOpen) return;
const previous = document.body.style.overflow;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = previous; };
return () => { }, [dashboardOpen, editorOpen]);
document.body.style.overflow = prev;
};
}, [dashboardOpen, userModalOpen]);
useEffect(() => { useEffect(() => {
if (!dashboardOpen) { if (!dashboardOpen) {
setUserModalOpen(false); setEditorOpen(false);
setMsg(""); setMsg("");
} }
}, [dashboardOpen]); }, [dashboardOpen]);
const loadUsers = async () => { const loadUsers = async () => setUsers(await api("/admin/users"));
const u = await api("/admin/users");
setUsers(u);
};
useEffect(() => { useEffect(() => { loadUsers().catch(() => {}); }, []);
loadUsers().catch(() => {});
}, []);
const resetForm = () => { const setField = (key, value) => setForm((current) => ({ ...current, [key]: value }));
setDisplayName("");
setEmail("");
setPassword("");
setRole("user");
};
const createUser = async () => { const openCreate = () => {
setEditingUser(null);
setForm({ ...emptyForm });
setMsg(""); setMsg("");
setEditorOpen(true);
};
const openEdit = (user) => {
setEditingUser(user);
setForm({
displayName: user.display_name || "",
email: user.email || "",
password: "",
role: user.role || "user",
disabled: !!user.disabled,
});
setMsg("");
setEditorOpen(true);
};
const closeEditor = () => {
setEditorOpen(false);
setEditingUser(null);
setMsg("");
};
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("❌ Neues Passwort muss mindestens 8 Zeichen haben.");
setSaving(true);
try { try {
await api("/admin/users", { const payload = {
method: "POST", display_name: form.displayName,
body: JSON.stringify({ display_name: displayName, email, password, role }), email: form.email,
role: form.role,
disabled: form.disabled,
};
if (form.password) payload.password = form.password;
await api(editingUser ? `/admin/users/${editingUser.id}` : "/admin/users", {
method: editingUser ? "PATCH" : "POST",
body: JSON.stringify(payload),
}); });
setMsg("✅ User erstellt.");
await loadUsers(); await loadUsers();
resetForm(); closeEditor();
setUserModalOpen(false);
} catch (e) { } catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown")); setMsg("❌ " + (e?.message || "Speichern fehlgeschlagen."));
} finally {
setSaving(false);
} }
}; };
const deleteUser = async (u) => { const disableUser = async (user) => {
if (!window.confirm(`User wirklich löschen (deaktivieren)?\n\n${u.display_name || u.email}`)) return; if (user.id === currentUserId) return;
if (!window.confirm(`User wirklich deaktivieren?\n\n${user.display_name || user.email}`)) return;
try { try {
await api(`/admin/users/${u.id}`, { method: "DELETE" }); await api(`/admin/users/${user.id}`, { method: "DELETE" });
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
alert("Fehler: " + (e?.message || "unknown")); alert("Fehler: " + (e?.message || "unknown"));
} }
}; };
const closeModal = () => {
setUserModalOpen(false);
setMsg("");
};
if (!dashboardOpen) return null; if (!dashboardOpen) return null;
return createPortal( return createPortal(
<div style={styles.modalOverlay} onMouseDown={onClose}> <div style={styles.modalOverlay} onMouseDown={onClose}>
<div <div style={{ ...styles.modalCard, width: "min(780px, 100%)", padding: 0 }} onMouseDown={(e) => e.stopPropagation()}>
style={{ ...styles.modalCard, width: "min(760px, 100%)", padding: 0 }} <div style={{ padding: "18px 18px 16px" }}>
onMouseDown={(e) => e.stopPropagation()} <div style={styles.modalHeader}>
> <div>
<div style={{ ...styles.adminWrap, marginTop: 0, border: "none", boxShadow: "none", backdropFilter: "none", WebkitBackdropFilter: "none" }}> <div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>Admin Dashboard</div>
<div style={styles.adminTop}> <div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>Benutzer, Rollen und Zugangsdaten verwalten</div>
<div style={styles.adminTitle}>Admin Dashboard</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button onClick={() => setUserModalOpen(true)} style={styles.primaryBtn}>
+ User anlegen
</button>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Dashboard schließen">
</button>
</div> </div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Dashboard schließen"></button>
</div> </div>
<div style={{ marginTop: 12, fontWeight: 900, color: stylesTokens.textGold }}> <div style={{ marginTop: 18, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}>
Vorhandene User <div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>Vorhandene User <span style={{ color: stylesTokens.textDim, fontWeight: 700 }}>({users.length})</span></div>
<button onClick={openCreate} style={styles.primaryBtn}>+ User anlegen</button>
</div> </div>
<div style={{ marginTop: 8, display: "grid", gap: 8 }}> <div style={{ marginTop: 10, display: "grid", gap: 9 }}>
{users.map((u) => ( {users.map((user) => (
<div <div key={user.id} className="hp-admin-user-row" style={{ ...styles.userRow, alignItems: "center" }}>
key={u.id} <div className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}>{user.display_name || "—"}</div>
className="hp-admin-user-row" <div className="hp-admin-email" style={{ color: stylesTokens.textDim, fontSize: 13 }}>{user.email}</div>
style={{ <div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>{user.role}</div>
...styles.userRow, <div className={`hp-admin-status ${user.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85 }}>{user.disabled ? "disabled" : "active"}</div>
alignItems: "center", <div className="hp-admin-actions">
}} <button className="hp-admin-action" onClick={() => openEdit(user)} style={{ ...styles.secondaryBtn, padding: "8px 11px" }}>Bearbeiten</button>
> <button className="hp-admin-action" onClick={() => disableUser(user)} disabled={user.id === currentUserId || user.disabled} style={{ ...styles.secondaryBtn, padding: "8px 11px", color: "#ffb3b3" }}>
<div className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}> {user.disabled ? "Deaktiviert" : "Deaktivieren"}
{u.display_name || "—"}
</div>
<div className="hp-admin-email" style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div>
<div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
{u.role}
</div>
<div className={`hp-admin-status ${u.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
{u.disabled ? "disabled" : "active"}
</div>
<button
className="hp-admin-action"
onClick={() => deleteUser(u)}
style={{
...styles.secondaryBtn,
padding: "8px 10px",
borderRadius: 12,
color: "#ffb3b3",
opacity: u.role === "admin" ? 0.4 : 1,
pointerEvents: u.role === "admin" ? "none" : "auto",
}}
title={u.role === "admin" ? "Admin kann nicht gelöscht werden" : "User löschen (deaktivieren)"}
>
Löschen
</button> </button>
</div> </div>
</div>
))} ))}
</div> </div>
</div>
</div>
{userModalOpen && {editorOpen && createPortal(
createPortal( <div style={styles.modalOverlay} onMouseDown={closeEditor}>
<div style={styles.modalOverlay} onMouseDown={closeModal}> <div style={{ ...styles.modalCard, width: "min(470px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}> <div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}> <div>
Neuen User anlegen <div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 18 }}>{editingUser ? "User bearbeiten" : "Neuen User anlegen"}</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{editingUser ? "Profil und Zugangsdaten aktualisieren" : "Ein neues Benutzerkonto erstellen"}</div>
</div> </div>
<button onClick={closeModal} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={closeEditor} style={styles.modalCloseBtn} aria-label="Schließen"></button>
</button>
</div> </div>
<div <div style={{ marginTop: 18, display: "grid", gap: 10 }}>
style={{ <label style={styles.adminFieldLabel}>Anzeigename<input value={form.displayName} onChange={(e) => setField("displayName", e.target.value)} placeholder="z. B. Sascha Nesterovic" style={styles.input} autoFocus /></label>
marginTop: 12, <label style={styles.adminFieldLabel}>E-Mail<input value={form.email} onChange={(e) => setField("email", e.target.value)} placeholder="name@example.com" style={styles.input} inputMode="email" /></label>
display: "grid", <label style={styles.adminFieldLabel}>{editingUser ? "Neues Passwort (optional)" : "Passwort"}<input value={form.password} onChange={(e) => setField("password", e.target.value)} placeholder={editingUser ? "Leer lassen = unverändert" : "Mindestens 8 Zeichen"} type="password" style={styles.input} /></label>
gap: 8, <label style={styles.adminFieldLabel}>Rolle<select value={form.role} onChange={(e) => setField("role", e.target.value)} disabled={editingUser?.id === currentUserId} style={styles.input}><option value="user">User</option><option value="admin">Admin</option></select></label>
justifyItems: "center", // <<< zentriert alles {editingUser && <label className="hp-admin-check"><input type="checkbox" checked={!form.disabled} onChange={(e) => setField("disabled", !e.target.checked)} /> Konto ist aktiv</label>}
}} {msg && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{msg}</div>}
> <button onClick={saveUser} style={{ ...styles.primaryBtn, width: "100%", marginTop: 4 }} disabled={saving}>{saving ? "Speichern …" : editingUser ? "Änderungen speichern" : "User erstellen"}</button>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Name (z.B. Sascha)"
style={styles.input}
autoFocus
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
style={styles.input}
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Initial Passwort"
type="password"
style={styles.input}
/>
<select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
{msg && <div style={{ opacity: 0.9, color: stylesTokens.textMain }}>{msg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button
onClick={() => {
resetForm();
setMsg("");
}}
style={styles.secondaryBtn}
>
Leeren
</button>
<button onClick={createUser} style={styles.primaryBtn}>
User erstellen
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Name wird in TopBar & Siegeranzeige genutzt.
</div>
</div> </div>
</div> </div>
</div>, </div>,
document.body document.body
) )}
}
</div>
</div>
</div>, </div>,
document.body document.body
); );
+1 -1
View File
@@ -167,7 +167,7 @@ export default function HelpModal({ open, onClose }) {
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🛡</span> <span style={styles.helpMiniTag}>🛡</span>
<div><b>Admin Dashboard</b> = nur für Administratoren</div> <div><b>Admin Dashboard</b> = User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren</div>
</div> </div>
</div> </div>
@@ -105,8 +105,11 @@ export function useHpGlobalStyles() {
@keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } } @keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } }
.hp-topbar-actions { margin-left: auto; } .hp-topbar-actions { margin-left: auto; }
.hp-user-menu-wrap { min-width: 0; } .hp-user-menu-wrap { min-width: 0; }
.hp-admin-user-row { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.5fr) 70px 76px 86px; } .hp-admin-user-row { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.5fr) 70px 76px 150px; }
.hp-admin-action { min-width: 0; } .hp-admin-action { min-width: 0; }
.hp-admin-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
.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-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-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 { color: #baf3c9 !important; font-size: 13px; }
.hp-admin-status--disabled { color: #ffb3b3 !important; } .hp-admin-status--disabled { color: #ffb3b3 !important; }
@@ -126,7 +129,8 @@ export function useHpGlobalStyles() {
.hp-admin-email { grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px !important; } .hp-admin-email { grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px !important; }
.hp-admin-role { grid-column: 2; grid-row: 1; } .hp-admin-role { grid-column: 2; grid-row: 1; }
.hp-admin-status { grid-column: 2; grid-row: 2; } .hp-admin-status { grid-column: 2; grid-row: 2; }
.hp-admin-action { grid-column: 2; grid-row: 3; justify-self: end; width: auto; min-width: 94px; min-height: 38px; padding: 7px 14px !important; } .hp-admin-actions { grid-column: 1 / -1; grid-row: 3; width: 100%; justify-content: flex-end; }
.hp-admin-actions .hp-admin-action { width: auto; min-width: 0; min-height: 38px; padding: 7px 10px !important; }
.hp-row { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; } .hp-row { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; }
.hp-row button { min-height: 40px; } .hp-row button { min-height: 40px; }
button { min-height: 42px; } button { min-height: 42px; }
+7
View File
@@ -180,6 +180,13 @@ export const styles = {
fontWeight: 1000, fontWeight: 1000,
color: stylesTokens.textGold, color: stylesTokens.textGold,
}, },
adminFieldLabel: {
display: "grid",
gap: 5,
color: stylesTokens.textDim,
fontSize: 12,
fontWeight: 800,
},
userRow: { userRow: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 80px 90px", gridTemplateColumns: "1fr 80px 90px",