diff --git a/src/components/pages/UserDetailPage.tsx b/src/components/pages/UserDetailPage.tsx
index 4e3a1a0..8350de2 100644
--- a/src/components/pages/UserDetailPage.tsx
+++ b/src/components/pages/UserDetailPage.tsx
@@ -1,13 +1,26 @@
import { useEffect, useState } from "react"
import { useParams, useNavigate } from "react-router"
-import { ArrowLeft, KeyRound, Plus, Trash2, User as UserIcon } from "lucide-react"
+import {
+ ArrowLeft,
+ KeyRound,
+ Plus,
+ Shield,
+ Trash2,
+ User as UserIcon,
+ UserCog,
+ type LucideIcon,
+} from "lucide-react"
import { toast } from "sonner"
-import { usersAPI, appsAPI, rolesAPI } from "@/services/api"
+import { usersAPI, appsAPI, projectsAPI, rolesAPI } from "@/services/api"
+import { useAuth } from "@/contexts/AuthContext"
import type {
UserListResponse,
+ UserRole,
+ UserRoleUpdate,
UserRoleResponse,
AppResponse,
AppRoleResponse,
+ ProjectResponse,
} from "@/types"
import { card } from "@/styles"
import { Button } from "@/components/ui/button"
@@ -35,17 +48,43 @@ import { ConfirmDialog } from "@/components/common/ConfirmDialog"
import { formatDate } from "@/utils/format"
+const roleBadge: Record<
+ UserRole,
+ { variant: "admin" | "manager" | "member"; icon: LucideIcon; label: string }
+> = {
+ platform_admin: { variant: "admin", icon: Shield, label: "Admin" },
+ manager: { variant: "manager", icon: UserCog, label: "Manager" },
+ member: { variant: "member", icon: UserIcon, label: "Member" },
+}
+
+const roleOptions: { value: UserRole; label: string }[] = [
+ { value: "member", label: "Member" },
+ { value: "manager", label: "Manager" },
+ { value: "platform_admin", label: "Platform Admin" },
+]
+
+function getUserRole(user: UserListResponse): UserRole {
+ return user.role ?? (user.is_platform_admin ? "platform_admin" : "member")
+}
+
function UserInfoCard({
user,
+ isSelf,
+ roleSaving,
+ onRoleSelect,
onToggleActive,
- onToggleAdmin,
onDelete,
}: {
user: UserListResponse
+ isSelf: boolean
+ roleSaving: boolean
+ onRoleSelect: (role: UserRole) => void
onToggleActive: () => void
- onToggleAdmin: () => void
onDelete: () => void
}) {
+ const role = getUserRole(user)
+ const { variant, icon: RoleIcon, label } = roleBadge[role]
+
return (
@@ -85,18 +124,36 @@ function UserInfoCard({
- Platform Admin
-
+ Role:
+
- {user.is_platform_admin ? (
- Yes
- ) : (
- No
- )}
-
+
+
+ {label}
+
+
@@ -204,6 +261,7 @@ function RolesTable({
export default function UserDetailPage() {
const { userId } = useParams<{ userId: string }>()
const navigate = useNavigate()
+ const { user: currentUser } = useAuth()
const [user, setUser] = useState
(null)
const [userLoading, setUserLoading] = useState(true)
@@ -223,6 +281,13 @@ export default function UserDetailPage() {
)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
+ const [managerDialogOpen, setManagerDialogOpen] = useState(false)
+ const [projects, setProjects] = useState([])
+ const [projectsLoading, setProjectsLoading] = useState(false)
+ const [selectedProjectIds, setSelectedProjectIds] = useState([])
+ const [roleSaving, setRoleSaving] = useState(false)
+ const [memberConfirmOpen, setMemberConfirmOpen] = useState(false)
+
async function fetchUser() {
if (!userId) return
try {
@@ -268,23 +333,68 @@ export default function UserDetailPage() {
}
}
- async function handleToggleAdmin() {
- if (!userId || !user) return
+ async function applyRoleUpdate(update: UserRoleUpdate) {
+ if (!userId) return false
+ setRoleSaving(true)
try {
- const { data } = await usersAPI.update(userId, {
- is_platform_admin: !user.is_platform_admin,
- })
- setUser(data)
- toast.success(
- data.is_platform_admin
- ? "User is now a platform admin"
- : "Platform admin removed",
- )
- } catch {
- toast.error("Failed to update user")
+ await usersAPI.updateRole(userId, update)
+ const roleLabel = roleOptions.find((o) => o.value === update.role)?.label
+ toast.success(`User role updated to ${roleLabel}`)
+ await fetchUser()
+ return true
+ } catch (err: unknown) {
+ const detail = (err as { response?: { data?: { detail?: string } } })
+ ?.response?.data?.detail
+ toast.error(detail || "Failed to update role")
+ return false
+ } finally {
+ setRoleSaving(false)
+ }
+ }
+
+ async function openManagerDialog() {
+ setSelectedProjectIds([])
+ setManagerDialogOpen(true)
+ if (projects.length === 0) {
+ setProjectsLoading(true)
+ try {
+ const { data } = await projectsAPI.list()
+ setProjects(data)
+ } catch {
+ toast.error("Failed to load projects")
+ } finally {
+ setProjectsLoading(false)
+ }
+ }
+ }
+
+ function handleRoleSelect(role: UserRole) {
+ if (!user || role === getUserRole(user)) return
+ if (role === "platform_admin") {
+ applyRoleUpdate({ role: "platform_admin" })
+ } else if (role === "manager") {
+ openManagerDialog()
+ } else {
+ setMemberConfirmOpen(true)
}
}
+ function toggleProjectSelection(projectId: string) {
+ setSelectedProjectIds((prev) =>
+ prev.includes(projectId)
+ ? prev.filter((id) => id !== projectId)
+ : [...prev, projectId],
+ )
+ }
+
+ async function handleConfirmManager() {
+ const ok = await applyRoleUpdate({
+ role: "manager",
+ project_ids: selectedProjectIds,
+ })
+ if (ok) setManagerDialogOpen(false)
+ }
+
async function handleDeleteUser() {
if (!userId) return
try {
@@ -398,8 +508,10 @@ export default function UserDetailPage() {
setDeleteConfirmOpen(true)}
/>
@@ -489,6 +601,68 @@ export default function UserDetailPage() {
+
+
+ applyRoleUpdate({ role: "member" })}
+ />
+
{
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
index c636ffe..d36597b 100644
--- a/src/components/ui/badge.tsx
+++ b/src/components/ui/badge.tsx
@@ -13,6 +13,8 @@ const badgeVariants = cva(
active: "bg-verde-claro/20 text-verde-claro",
inactive: "bg-areia/30 text-verde",
admin: "bg-telha/15 text-telha border border-telha/30",
+ manager: "bg-azul/25 text-verde border border-azul/60",
+ member: "bg-areia/30 text-verde border border-areia",
},
},
defaultVariants: {
diff --git a/src/services/api.ts b/src/services/api.ts
index d0f3a8c..90bc07c 100644
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -8,6 +8,7 @@ import type {
MyManagedOrgsResponse,
UserListResponse,
UserUpdate,
+ UserRoleUpdate,
UserRoleResponse,
AppResponse,
AppCreate,
@@ -145,6 +146,8 @@ export const usersAPI = {
get: (userId: string) => api.get(`/users/${userId}`),
update: (userId: string, data: UserUpdate) =>
api.patch(`/users/${userId}`, data),
+ updateRole: (userId: string, data: UserRoleUpdate) =>
+ api.put(`/users/${userId}/role`, data),
delete: (userId: string) => api.delete(`/users/${userId}`),
listRoles: (userId: string) =>
api.get(`/users/${userId}/roles`),
diff --git a/src/types/index.ts b/src/types/index.ts
index e343aa0..1c0bb4b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -9,8 +9,10 @@ export type {
} from "./auth"
export type {
+ UserRole,
UserListResponse,
UserUpdate,
+ UserRoleUpdate,
UserRoleResponse,
} from "./user"
diff --git a/src/types/user.ts b/src/types/user.ts
index 42c8b33..012c4a1 100644
--- a/src/types/user.ts
+++ b/src/types/user.ts
@@ -1,3 +1,5 @@
+export type UserRole = "member" | "manager" | "platform_admin";
+
export interface UserListResponse {
id: string
email: string
@@ -5,6 +7,7 @@ export interface UserListResponse {
avatar_url: string | null
is_active: boolean
is_platform_admin: boolean
+ role?: UserRole;
created_at: string
}
@@ -14,6 +17,11 @@ export interface UserUpdate {
avatar_url?: string | null
}
+export interface UserRoleUpdate {
+ role: UserRole;
+ project_ids?: string[];
+}
+
export interface UserRoleResponse {
app_key: string
role_key: string