From a532cae9bd828423eae3d5f8f31b6dec7c50853c Mon Sep 17 00:00:00 2001 From: nessi Date: Sun, 2 Aug 2026 10:11:38 +0200 Subject: [PATCH] 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 --- backend/app/routes/admin.py | 45 ++- frontend/src/App.jsx | 2 +- frontend/src/components/AdminPanel.jsx | 291 +++++++----------- frontend/src/components/HelpModal.jsx | 2 +- .../src/styles/hooks/useHpGlobalStyles.js | 8 +- frontend/src/styles/styles.js | 7 + 6 files changed, 172 insertions(+), 183 deletions(-) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index fbcb723..47e74da 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -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} \ No newline at end of file + 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} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5f49eba..c5d18b1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -699,7 +699,7 @@ export default function App() { /> {me.role === "admin" && ( - setAdminOpen(false)} /> + setAdminOpen(false)} currentUserId={me.id} /> )} { - 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(
-
e.stopPropagation()} - > -
-
-
Admin Dashboard
-
- - -
-
- -
- Vorhandene User -
- -
- {users.map((u) => ( -
-
- {u.display_name || "—"} +
e.stopPropagation()}> +
+
+
+
Admin Dashboard
+
Benutzer, Rollen und Zugangsdaten verwalten
-
{u.email}
-
- {u.role} -
-
- {u.disabled ? "disabled" : "active"} -
- - +
- ))} -
- {userModalOpen && - createPortal( -
-
e.stopPropagation()}> -
-
- Neuen User anlegen -
- -
+
+
Vorhandene User ({users.length})
+ +
-
- setDisplayName(e.target.value)} - placeholder="Name (z.B. Sascha)" - style={styles.input} - autoFocus - /> - - setEmail(e.target.value)} - placeholder="Email" - style={styles.input} - /> - - setPassword(e.target.value)} - placeholder="Initial Passwort" - type="password" - style={styles.input} - /> - - - - {msg &&
{msg}
} - -
- - +
- -
- Tipp: Name wird in TopBar & Siegeranzeige genutzt. -
-
-
, - document.body - ) - } + ))} +
+ + {editorOpen && createPortal( +
+
e.stopPropagation()}> +
+
+
{editingUser ? "User bearbeiten" : "Neuen User anlegen"}
+
{editingUser ? "Profil und Zugangsdaten aktualisieren" : "Ein neues Benutzerkonto erstellen"}
+
+ +
+ +
+ + + + + {editingUser && } + {msg &&
{msg}
} + +
+
+
, + document.body + )}
, document.body ); diff --git a/frontend/src/components/HelpModal.jsx b/frontend/src/components/HelpModal.jsx index 328123d..24bf3e7 100644 --- a/frontend/src/components/HelpModal.jsx +++ b/frontend/src/components/HelpModal.jsx @@ -167,7 +167,7 @@ export default function HelpModal({ open, onClose }) {
🛡️ -
Admin Dashboard = nur für Administratoren
+
Admin Dashboard = User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren
diff --git a/frontend/src/styles/hooks/useHpGlobalStyles.js b/frontend/src/styles/hooks/useHpGlobalStyles.js index a8b1c48..2221474 100644 --- a/frontend/src/styles/hooks/useHpGlobalStyles.js +++ b/frontend/src/styles/hooks/useHpGlobalStyles.js @@ -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; } diff --git a/frontend/src/styles/styles.js b/frontend/src/styles/styles.js index 570d230..1c7012a 100644 --- a/frontend/src/styles/styles.js +++ b/frontend/src/styles/styles.js @@ -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",