Add permanent user deletion endpoint with cascade cleanup and trash icon button in admin panel

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
This commit is contained in:
2026-08-02 10:44:40 +02:00
parent 45fc8e4c9e
commit 565d538c35
4 changed files with 44 additions and 11 deletions
+23 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..mailer import send_html_email, send_user_invite 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 from ..security import hash_password, get_session_user_id
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -84,6 +84,28 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
db.commit() db.commit()
return {"ok": True, "id": u.id, "invite_sent": invite_sent} 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}") @router.delete("/users/{user_id}")
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)): def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
admin = require_admin(req, db) admin = require_admin(req, db)
+14
View File
@@ -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; if (!dashboardOpen) return null;
return createPortal( return createPortal(
@@ -136,6 +149,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
<div className="hp-admin-actions"> <div className="hp-admin-actions">
<button className="hp-admin-action hp-admin-icon-action" onClick={() => openEdit(user)} style={styles.secondaryBtn} title={t("edit")} aria-label={`${user.display_name || user.email} ${t("edit")}`}></button> <button className="hp-admin-action hp-admin-icon-action" onClick={() => openEdit(user)} style={styles.secondaryBtn} title={t("edit")} aria-label={`${user.display_name || user.email} ${t("edit")}`}></button>
<button className="hp-admin-action hp-admin-icon-action" onClick={() => disableUser(user)} disabled={user.id === currentUserId || user.disabled} style={{ ...styles.secondaryBtn, color: "#ffb3b3" }} title={user.disabled ? t("disabled") : t("disable")} aria-label={`${user.display_name || user.email} ${t("disable")}`}></button> <button className="hp-admin-action hp-admin-icon-action" onClick={() => disableUser(user)} disabled={user.id === currentUserId || user.disabled} style={{ ...styles.secondaryBtn, color: "#ffb3b3" }} title={user.disabled ? t("disabled") : t("disable")} aria-label={`${user.display_name || user.email} ${t("disable")}`}></button>
<button className="hp-admin-action hp-admin-icon-action" onClick={() => permanentlyDeleteUser(user)} disabled={user.id === currentUserId} style={{ ...styles.secondaryBtn, color: "#ff8f9b" }} title={language === "en" ? "Delete permanently" : "Dauerhaft löschen"} aria-label={`${user.display_name || user.email} ${language === "en" ? "delete permanently" : "dauerhaft löschen"}`}>🗑</button>
</div> </div>
</div> </div>
))} ))}
@@ -105,10 +105,10 @@ 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.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-action { min-width: 0; }
.hp-admin-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; 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-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 { 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-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-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 { 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-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-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 { grid-column: 1; grid-row: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 16px; } .hp-admin-name, .hp-admin-email { min-width: 0; 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, .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-role { grid-column: 2; grid-row: 1; } .hp-admin-actions { width: auto; justify-content: flex-end; gap: 3px; }
.hp-admin-status { grid-column: 2; grid-row: 2; } .hp-admin-actions .hp-admin-action { width: 29px; min-width: 29px; height: 29px; min-height: 29px; padding: 0 !important; font-size: 14px; }
.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-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; } .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-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; } .hp-admin-settings-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; overflow: auto; }
-1
View File
@@ -189,7 +189,6 @@ export const styles = {
}, },
userRow: { userRow: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 80px 90px",
gap: 8, gap: 8,
padding: 10, padding: 10,
borderRadius: 12, borderRadius: 12,