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 <noreply@anthropic.com>
This commit is contained in:
@@ -133,7 +133,7 @@ async def require_engineer_or_admin(
|
|||||||
"""Require engineer, account owner, or super admin role (blocks viewers)."""
|
"""Require engineer, account owner, or super admin role (blocks viewers)."""
|
||||||
if current_user.is_super_admin:
|
if current_user.is_super_admin:
|
||||||
return current_user
|
return current_user
|
||||||
if current_user.account_role in ("owner", "engineer"):
|
if current_user.account_role in ("owner", "admin", "engineer"):
|
||||||
return current_user
|
return current_user
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
|||||||
@@ -371,6 +371,46 @@ async def update_account_role(
|
|||||||
return user
|
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)
|
@router.put("/users/{user_id}/deactivate", response_model=UserResponse)
|
||||||
async def deactivate_user(
|
async def deactivate_user(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const adminApi = {
|
|||||||
api.put(`/admin/users/${id}/role`, { role }).then(r => r.data),
|
api.put(`/admin/users/${id}/role`, { role }).then(r => r.data),
|
||||||
updateAccountRole: (id: string, account_role: string) =>
|
updateAccountRole: (id: string, account_role: string) =>
|
||||||
api.put(`/admin/users/${id}/account-role`, { account_role }).then(r => r.data),
|
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) =>
|
deactivateUser: (id: string) =>
|
||||||
api.put(`/admin/users/${id}/deactivate`).then(r => r.data),
|
api.put(`/admin/users/${id}/deactivate`).then(r => r.data),
|
||||||
activateUser: (id: string) =>
|
activateUser: (id: string) =>
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export function UserDetailPage() {
|
|||||||
const [resetTempPassword, setResetTempPassword] = useState<string | null>(null)
|
const [resetTempPassword, setResetTempPassword] = useState<string | null>(null)
|
||||||
const [resetCopied, setResetCopied] = useState(false)
|
const [resetCopied, setResetCopied] = useState(false)
|
||||||
|
|
||||||
|
// Super admin
|
||||||
|
const [superAdminModalOpen, setSuperAdminModalOpen] = useState(false)
|
||||||
|
|
||||||
// Hard delete
|
// Hard delete
|
||||||
const [hardDeleteModalOpen, setHardDeleteModalOpen] = useState(false)
|
const [hardDeleteModalOpen, setHardDeleteModalOpen] = useState(false)
|
||||||
const [hardDeleteChecking, setHardDeleteChecking] = useState(false)
|
const [hardDeleteChecking, setHardDeleteChecking] = useState(false)
|
||||||
@@ -119,6 +122,18 @@ export function UserDetailPage() {
|
|||||||
setTimeout(() => setResetCopied(false), 2000)
|
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 () => {
|
const handleArchive = async () => {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
try {
|
try {
|
||||||
@@ -317,6 +332,18 @@ export function UserDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setSuperAdminModalOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left text-sm',
|
||||||
|
user.is_super_admin
|
||||||
|
? 'border-yellow-500/20 text-yellow-400 hover:bg-yellow-500/5'
|
||||||
|
: 'border-purple-500/20 text-purple-400 hover:bg-purple-500/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Crown className="h-4 w-4" />
|
||||||
|
{user.is_super_admin ? 'Remove Super Admin' : 'Promote to Super Admin'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setResetMode('email_link')
|
setResetMode('email_link')
|
||||||
@@ -665,6 +692,47 @@ export function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Super Admin Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={superAdminModalOpen}
|
||||||
|
onClose={() => setSuperAdminModalOpen(false)}
|
||||||
|
title={user.is_super_admin ? 'Remove Super Admin Access' : 'Promote to Super Admin'}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setSuperAdminModalOpen(false)}
|
||||||
|
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleSuperAdmin}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-4 py-2 text-sm font-medium text-white',
|
||||||
|
user.is_super_admin
|
||||||
|
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||||
|
: 'bg-gradient-brand shadow-lg shadow-primary/20 hover:opacity-90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{user.is_super_admin ? 'Remove Access' : 'Promote'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{user.is_super_admin ? (
|
||||||
|
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
|
||||||
|
This will remove system-wide admin access from <strong>{user.full_name || user.email}</strong>. They will revert to their account role permissions.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-purple-400/20 bg-purple-400/10 p-3 text-sm text-purple-400">
|
||||||
|
This will grant <strong>{user.full_name || user.email}</strong> full system-wide admin access, including access to the admin panel and all accounts.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Hard Delete Modal */}
|
{/* Hard Delete Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={hardDeleteModalOpen}
|
isOpen={hardDeleteModalOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user