From 565d538c3551a251b87b1808584d9daf078f3007 Mon Sep 17 00:00:00 2001 From: nessi Date: Sun, 2 Aug 2026 10:44:40 +0200 Subject: [PATCH] Add permanent user deletion endpoint with cascade cleanup and trash icon button in admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented DELETE `/admin/users/{user_id}/permanent` endpoint with safeguards preventing self-deletion and blocking deletion of game hosts. Added cascade deletion of user's game memberships, chips, sheet states, and invite tokens, with winner references nullified. Added trash icon button (🗑) to admin user rows with bilingual confirmation dialog and error handling for game ownership conflicts. Adjusted --- backend/app/routes/admin.py | 24 ++++++++++++++++++- frontend/src/components/AdminPanel.jsx | 14 +++++++++++ .../src/styles/hooks/useHpGlobalStyles.js | 16 ++++++------- frontend/src/styles/styles.js | 1 - 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 3a5c26a..8209ff2 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from ..db import get_db from ..mailer import send_html_email, send_user_invite -from ..models import AppSettings, InviteToken, User, Role +from ..models import AppSettings, Game, GameChip, GameMember, InviteToken, SheetState, User, Role from ..security import hash_password, get_session_user_id router = APIRouter(prefix="/admin", tags=["admin"]) @@ -84,6 +84,28 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)): db.commit() return {"ok": True, "id": u.id, "invite_sent": invite_sent} +@router.delete("/users/{user_id}/permanent") +def permanently_delete_user(req: Request, user_id: str, db: Session = Depends(get_db)): + admin = require_admin(req, db) + if admin.id == user_id: + raise HTTPException(400, "cannot delete yourself") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "not found") + if db.query(Game).filter(Game.host_user_id == user_id).first(): + raise HTTPException(409, "cannot delete a user who owns games") + + db.query(Game).filter(Game.winner_user_id == user_id).update({Game.winner_user_id: None}, synchronize_session=False) + db.query(GameMember).filter(GameMember.user_id == user_id).delete(synchronize_session=False) + db.query(GameChip).filter(GameChip.user_id == user_id).delete(synchronize_session=False) + db.query(SheetState).filter(SheetState.owner_user_id == user_id).delete(synchronize_session=False) + db.query(InviteToken).filter(InviteToken.user_id == user_id).delete(synchronize_session=False) + db.delete(user) + db.commit() + return {"ok": True, "deleted": True} + + @router.delete("/users/{user_id}") def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)): admin = require_admin(req, db) diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 2cb97f3..d5deb12 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -106,6 +106,19 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre } }; + const permanentlyDeleteUser = async (user) => { + if (user.id === currentUserId) return; + const label = user.display_name || user.email; + if (!window.confirm(language === "en" ? `Delete ${label} permanently? This cannot be undone.` : `${label} wirklich dauerhaft löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`)) return; + try { + await api(`/admin/users/${user.id}/permanent`, { method: "DELETE" }); + await loadUsers(); + setNotice(language === "en" ? "✅ User permanently deleted." : "✅ User dauerhaft gelöscht."); + } catch (e) { + alert((language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown")); + } + }; + if (!dashboardOpen) return null; return createPortal( @@ -136,6 +149,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
+
))} diff --git a/frontend/src/styles/hooks/useHpGlobalStyles.js b/frontend/src/styles/hooks/useHpGlobalStyles.js index adb0622..ee48a9c 100644 --- a/frontend/src/styles/hooks/useHpGlobalStyles.js +++ b/frontend/src/styles/hooks/useHpGlobalStyles.js @@ -105,10 +105,10 @@ 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.25fr) minmax(0, 1.45fr) 62px 62px 78px; white-space: nowrap; } + .hp-admin-user-row { grid-template-columns: minmax(90px, 1.2fr) minmax(90px, 1.5fr) 58px 58px 100px; white-space: nowrap; min-height: 42px; padding: 6px 8px !important; } .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-icon-action { width: 34px; height: 34px; padding: 0 !important; display: inline-flex; align-items: center; justify-content: center; font-size: 17px; } + .hp-admin-icon-action { width: 30px; height: 30px; padding: 0 !important; display: inline-flex; align-items: center; justify-content: center; font-size: 15px; } .hp-admin-name, .hp-admin-email { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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); } @@ -134,13 +134,11 @@ export function useHpGlobalStyles() { .hp-topbar-user { justify-content: center; } .hp-user-dropdown { left: 50% !important; right: auto !important; transform: translateX(-50%); width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; } .hp-user-dropdown > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .hp-admin-user-row { grid-template-columns: minmax(0, 1fr) auto; gap: 4px 10px; padding: 11px !important; border-radius: 15px !important; } - .hp-admin-name { grid-column: 1; grid-row: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 16px; } - .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-actions { grid-column: 1 / -1; grid-row: 3; width: 100%; justify-content: flex-end; } - .hp-admin-actions .hp-admin-action { width: 36px; min-width: 36px; min-height: 36px; padding: 0 !important; } + .hp-admin-user-row { grid-template-columns: minmax(65px, 1.1fr) minmax(0, 1.3fr) 42px 42px 94px; gap: 3px; padding: 7px !important; border-radius: 12px !important; min-height: 42px; } + .hp-admin-name, .hp-admin-email { min-width: 0; font-size: 12px !important; } + .hp-admin-role, .hp-admin-status { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 11px !important; padding-left: 2px !important; padding-right: 2px !important; } + .hp-admin-actions { width: auto; justify-content: flex-end; gap: 3px; } + .hp-admin-actions .hp-admin-action { width: 29px; min-width: 29px; height: 29px; min-height: 29px; padding: 0 !important; font-size: 14px; } .hp-admin-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; } .hp-admin-editor-card input, .hp-admin-editor-card select { min-height: 40px; padding: 8px 10px !important; } .hp-admin-settings-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; overflow: auto; } diff --git a/frontend/src/styles/styles.js b/frontend/src/styles/styles.js index 1c7012a..1bdca21 100644 --- a/frontend/src/styles/styles.js +++ b/frontend/src/styles/styles.js @@ -189,7 +189,6 @@ export const styles = { }, userRow: { display: "grid", - gridTemplateColumns: "1fr 80px 90px", gap: 8, padding: 10, borderRadius: 12,