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:
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user