From 9dc667eb3c9fde1047243370522a7296727887b0 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 23:05:42 -0500 Subject: [PATCH] feat: super admin promote/demote endpoint + admin panel UI Fix require_engineer_or_admin missing "admin" account_role, add PUT /admin/users/{id}/super-admin endpoint with audit logging, and promote/demote button with confirmation modal on UserDetailPage. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/deps.py | 2 +- backend/app/api/endpoints/admin.py | 40 ++++++++++++ frontend/src/api/admin.ts | 2 + frontend/src/pages/admin/UserDetailPage.tsx | 68 +++++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index a23f7d59..e366cf38 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -133,7 +133,7 @@ async def require_engineer_or_admin( """Require engineer, account owner, or super admin role (blocks viewers).""" if current_user.is_super_admin: return current_user - if current_user.account_role in ("owner", "engineer"): + if current_user.account_role in ("owner", "admin", "engineer"): return current_user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index efb540f7..8450e0bd 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -371,6 +371,46 @@ async def update_account_role( return user +@router.put("/users/{user_id}/super-admin", response_model=UserResponse) +async def update_super_admin_status( + user_id: UUID, + data: dict, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_admin)] +): + """Promote or demote a user to/from super admin (super admin only).""" + is_super_admin = data.get("is_super_admin") + if not isinstance(is_super_admin, bool): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="is_super_admin must be a boolean" + ) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if user.id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot change your own super admin status" + ) + + old_status = user.is_super_admin + user.is_super_admin = is_super_admin + action = "user.promote_super_admin" if is_super_admin else "user.demote_super_admin" + await log_audit(db, current_user.id, action, "user", user.id, + {"email": user.email, "old_is_super_admin": old_status, "new_is_super_admin": is_super_admin}) + await db.commit() + await db.refresh(user) + return user + + @router.put("/users/{user_id}/deactivate", response_model=UserResponse) async def deactivate_user( user_id: UUID, diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 668e347e..813f4dcf 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -38,6 +38,8 @@ export const adminApi = { api.put(`/admin/users/${id}/role`, { role }).then(r => r.data), updateAccountRole: (id: string, account_role: string) => api.put(`/admin/users/${id}/account-role`, { account_role }).then(r => r.data), + updateSuperAdminStatus: (id: string, is_super_admin: boolean) => + api.put(`/admin/users/${id}/super-admin`, { is_super_admin }).then(r => r.data), deactivateUser: (id: string) => api.put(`/admin/users/${id}/deactivate`).then(r => r.data), activateUser: (id: string) => diff --git a/frontend/src/pages/admin/UserDetailPage.tsx b/frontend/src/pages/admin/UserDetailPage.tsx index 69f6c575..646ec7cc 100644 --- a/frontend/src/pages/admin/UserDetailPage.tsx +++ b/frontend/src/pages/admin/UserDetailPage.tsx @@ -32,6 +32,9 @@ export function UserDetailPage() { const [resetTempPassword, setResetTempPassword] = useState(null) const [resetCopied, setResetCopied] = useState(false) + // Super admin + const [superAdminModalOpen, setSuperAdminModalOpen] = useState(false) + // Hard delete const [hardDeleteModalOpen, setHardDeleteModalOpen] = useState(false) const [hardDeleteChecking, setHardDeleteChecking] = useState(false) @@ -119,6 +122,18 @@ export function UserDetailPage() { setTimeout(() => setResetCopied(false), 2000) } + const handleToggleSuperAdmin = async () => { + if (!userId || !user) return + try { + await adminApi.updateSuperAdminStatus(userId, !user.is_super_admin) + toast.success(user.is_super_admin ? 'Super admin access removed' : 'Promoted to super admin') + setSuperAdminModalOpen(false) + fetchUser() + } catch { + toast.error('Failed to update super admin status') + } + } + const handleArchive = async () => { if (!userId) return try { @@ -317,6 +332,18 @@ export function UserDetailPage() { )} + + + + } + > +
+ {user.is_super_admin ? ( +
+ This will remove system-wide admin access from {user.full_name || user.email}. They will revert to their account role permissions. +
+ ) : ( +
+ This will grant {user.full_name || user.email} full system-wide admin access, including access to the admin panel and all accounts. +
+ )} +
+ + {/* Hard Delete Modal */}