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
+41 -4
View File
@@ -61,11 +61,48 @@ def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
if not u:
raise HTTPException(404, "not found")
if u.role == Role.admin.value:
raise HTTPException(400, "cannot delete admin user")
# soft delete
u.disabled = True
db.add(u)
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" && (
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} />
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} currentUserId={me.id} />
)}
<GamePickerCard
+116 -175
View File
@@ -1,226 +1,167 @@
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";
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 [userModalOpen, setUserModalOpen] = useState(false);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [editorOpen, setEditorOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [form, setForm] = useState(emptyForm);
const [msg, setMsg] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!dashboardOpen && !userModalOpen) return;
const prev = document.body.style.overflow;
if (!dashboardOpen && !editorOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [dashboardOpen, userModalOpen]);
return () => { document.body.style.overflow = previous; };
}, [dashboardOpen, editorOpen]);
useEffect(() => {
if (!dashboardOpen) {
setUserModalOpen(false);
setEditorOpen(false);
setMsg("");
}
}, [dashboardOpen]);
const loadUsers = async () => {
const u = await api("/admin/users");
setUsers(u);
};
const loadUsers = async () => setUsers(await api("/admin/users"));
useEffect(() => {
loadUsers().catch(() => {});
}, []);
useEffect(() => { loadUsers().catch(() => {}); }, []);
const resetForm = () => {
setDisplayName("");
setEmail("");
setPassword("");
setRole("user");
};
const setField = (key, value) => setForm((current) => ({ ...current, [key]: value }));
const createUser = async () => {
const openCreate = () => {
setEditingUser(null);
setForm({ ...emptyForm });
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 {
await api("/admin/users", {
method: "POST",
body: JSON.stringify({ display_name: displayName, email, password, role }),
const payload = {
display_name: form.displayName,
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();
resetForm();
setUserModalOpen(false);
closeEditor();
} catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown"));
setMsg("❌ " + (e?.message || "Speichern fehlgeschlagen."));
} finally {
setSaving(false);
}
};
const deleteUser = async (u) => {
if (!window.confirm(`User wirklich löschen (deaktivieren)?\n\n${u.display_name || u.email}`)) return;
const disableUser = async (user) => {
if (user.id === currentUserId) return;
if (!window.confirm(`User wirklich deaktivieren?\n\n${user.display_name || user.email}`)) return;
try {
await api(`/admin/users/${u.id}`, { method: "DELETE" });
await api(`/admin/users/${user.id}`, { method: "DELETE" });
await loadUsers();
} catch (e) {
alert("Fehler: " + (e?.message || "unknown"));
}
};
const closeModal = () => {
setUserModalOpen(false);
setMsg("");
};
if (!dashboardOpen) return null;
return createPortal(
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div
style={{ ...styles.modalCard, width: "min(760px, 100%)", padding: 0 }}
onMouseDown={(e) => e.stopPropagation()}
>
<div style={{ ...styles.adminWrap, marginTop: 0, border: "none", boxShadow: "none", backdropFilter: "none", WebkitBackdropFilter: "none" }}>
<div style={styles.adminTop}>
<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>
<div style={{ marginTop: 12, fontWeight: 900, color: stylesTokens.textGold }}>
Vorhandene User
</div>
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
{users.map((u) => (
<div
key={u.id}
className="hp-admin-user-row"
style={{
...styles.userRow,
alignItems: "center",
}}
>
<div className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{u.display_name || "—"}
<div style={{ ...styles.modalCard, width: "min(780px, 100%)", padding: 0 }} onMouseDown={(e) => e.stopPropagation()}>
<div style={{ padding: "18px 18px 16px" }}>
<div style={styles.modalHeader}>
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>Admin Dashboard</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>Benutzer, Rollen und Zugangsdaten verwalten</div>
</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 onClick={onClose} style={styles.modalCloseBtn} aria-label="Dashboard schließen"></button>
</div>
))}
</div>
{userModalOpen &&
createPortal(
<div style={styles.modalOverlay} onMouseDown={closeModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Neuen User anlegen
</div>
<button onClick={closeModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 18, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}>
<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
style={{
marginTop: 12,
display: "grid",
gap: 8,
justifyItems: "center", // <<< zentriert alles
}}
>
<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
<div style={{ marginTop: 10, display: "grid", gap: 9 }}>
{users.map((user) => (
<div key={user.id} className="hp-admin-user-row" style={{ ...styles.userRow, alignItems: "center" }}>
<div className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}>{user.display_name || "—"}</div>
<div className="hp-admin-email" style={{ color: stylesTokens.textDim, fontSize: 13 }}>{user.email}</div>
<div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>{user.role}</div>
<div className={`hp-admin-status ${user.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85 }}>{user.disabled ? "disabled" : "active"}</div>
<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" }}>
{user.disabled ? "Deaktiviert" : "Deaktivieren"}
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Name wird in TopBar & Siegeranzeige genutzt.
</div>
</div>
</div>
</div>,
document.body
)
}
))}
</div>
</div>
</div>
{editorOpen && createPortal(
<div style={styles.modalOverlay} onMouseDown={closeEditor}>
<div style={{ ...styles.modalCard, width: "min(470px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div>
<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>
<button onClick={closeEditor} style={styles.modalCloseBtn} aria-label="Schließen"></button>
</div>
<div style={{ marginTop: 18, display: "grid", gap: 10 }}>
<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>
<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>
<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>
<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>
{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>
</div>
</div>
</div>,
document.body
)}
</div>,
document.body
);
+1 -1
View File
@@ -167,7 +167,7 @@ export default function HelpModal({ open, onClose }) {
</div>
<div style={styles.helpListRow}>
<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>
@@ -105,8 +105,11 @@ export function useHpGlobalStyles() {
@keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } }
.hp-topbar-actions { margin-left: auto; }
.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-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-status { color: #baf3c9 !important; font-size: 13px; }
.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-role { grid-column: 2; grid-row: 1; }
.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 button { min-height: 40px; }
button { min-height: 42px; }
+7
View File
@@ -180,6 +180,13 @@ export const styles = {
fontWeight: 1000,
color: stylesTokens.textGold,
},
adminFieldLabel: {
display: "grid",
gap: 5,
color: stylesTokens.textDim,
fontSize: 12,
fontWeight: 800,
},
userRow: {
display: "grid",
gridTemplateColumns: "1fr 80px 90px",