Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 202 additions & 28 deletions src/components/pages/UserDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 (
<div className={`${card.base} p-4 sm:p-6`}>
<div className="flex items-start gap-3 sm:gap-4 mb-4">
Expand Down Expand Up @@ -85,18 +124,36 @@ function UserInfoCard({
<div className="flex items-center gap-2">
<span className="text-verde">
<span className="inline-flex items-center">
Platform Admin
<InfoTooltip content="Platform admins have full access to all resources including user management and app configuration." />
Role:
<InfoTooltip
content={
isSelf
? "You cannot change your own role"
: "Members have basic access, managers oversee specific projects, and platform admins have full access to all resources."
}
/>
</span>
</span>
{user.is_platform_admin ? (
<Badge variant="success">Yes</Badge>
) : (
<Badge variant="default">No</Badge>
)}
<Button variant="ghost" size="sm" onClick={onToggleAdmin}>
{user.is_platform_admin ? "Remove Admin" : "Make Admin"}
</Button>
<Badge variant={variant}>
<RoleIcon className="h-3 w-3 mr-1" />
{label}
</Badge>
<Select
value={role}
onValueChange={(value) => onRoleSelect(value as UserRole)}
disabled={isSelf || roleSaving}
>
<SelectTrigger className="h-8 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 text-sm text-verde">
Expand Down Expand Up @@ -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<UserListResponse | null>(null)
const [userLoading, setUserLoading] = useState(true)
Expand All @@ -223,6 +281,13 @@ export default function UserDetailPage() {
)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)

const [managerDialogOpen, setManagerDialogOpen] = useState(false)
const [projects, setProjects] = useState<ProjectResponse[]>([])
const [projectsLoading, setProjectsLoading] = useState(false)
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([])
const [roleSaving, setRoleSaving] = useState(false)
const [memberConfirmOpen, setMemberConfirmOpen] = useState(false)

async function fetchUser() {
if (!userId) return
try {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -398,8 +508,10 @@ export default function UserDetailPage() {

<UserInfoCard
user={user}
isSelf={currentUser?.id === userId}
roleSaving={roleSaving}
onRoleSelect={handleRoleSelect}
onToggleActive={handleToggleActive}
onToggleAdmin={handleToggleAdmin}
onDelete={() => setDeleteConfirmOpen(true)}
/>

Expand Down Expand Up @@ -489,6 +601,68 @@ export default function UserDetailPage() {
</DialogContent>
</Dialog>

<Dialog open={managerDialogOpen} onOpenChange={setManagerDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Select projects this user will manage</DialogTitle>
<DialogDescription>
Managers oversee specific projects. Select at least one project
to grant this user the manager role.
</DialogDescription>
</DialogHeader>
{projectsLoading ? (
<p className="text-sm text-verde">Loading projects...</p>
) : projects.length === 0 ? (
<p className="text-sm text-verde">
No projects available. Create a project before assigning a
manager.
</p>
) : (
<div className="max-h-64 overflow-y-auto rounded-lg border border-areia/30 divide-y divide-areia/10">
{projects.map((project) => (
<label
key={project.id}
className="flex items-center gap-3 px-3 py-2.5 cursor-pointer hover:bg-surface-alt/50"
>
<input
type="checkbox"
className="h-4 w-4 accent-telha"
checked={selectedProjectIds.includes(project.id)}
onChange={() => toggleProjectSelection(project.id)}
/>
<span className="text-sm text-preto">{project.name}</span>
</label>
))}
</div>
)}
<DialogFooter className="border-t border-areia/10 pt-4 mt-2">
<Button
variant="outline"
onClick={() => setManagerDialogOpen(false)}
disabled={roleSaving}
>
Cancel
</Button>
<Button
onClick={handleConfirmManager}
disabled={roleSaving || selectedProjectIds.length === 0}
>
{roleSaving ? "Saving..." : "Make Manager"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

<ConfirmDialog
open={memberConfirmOpen}
onOpenChange={setMemberConfirmOpen}
title="Set Role to Member"
description={`Are you sure you want to set "${user.display_name || user.email}" as a member? All projects this user currently manages will be demoted to member access.`}
confirmLabel="Set as Member"
variant="default"
onConfirm={() => applyRoleUpdate({ role: "member" })}
/>

<ConfirmDialog
open={revokingRole !== null}
onOpenChange={(open) => {
Expand Down
2 changes: 2 additions & 0 deletions src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
MyManagedOrgsResponse,
UserListResponse,
UserUpdate,
UserRoleUpdate,
UserRoleResponse,
AppResponse,
AppCreate,
Expand Down Expand Up @@ -145,6 +146,8 @@ export const usersAPI = {
get: (userId: string) => api.get<UserListResponse>(`/users/${userId}`),
update: (userId: string, data: UserUpdate) =>
api.patch<UserListResponse>(`/users/${userId}`, data),
updateRole: (userId: string, data: UserRoleUpdate) =>
api.put<UserListResponse>(`/users/${userId}/role`, data),
delete: (userId: string) => api.delete(`/users/${userId}`),
listRoles: (userId: string) =>
api.get<UserRoleResponse[]>(`/users/${userId}/roles`),
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ export type {
} from "./auth"

export type {
UserRole,
UserListResponse,
UserUpdate,
UserRoleUpdate,
UserRoleResponse,
} from "./user"

Expand Down
8 changes: 8 additions & 0 deletions src/types/user.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
export type UserRole = "member" | "manager" | "platform_admin";

export interface UserListResponse {
id: string
email: string
display_name: string | null
avatar_url: string | null
is_active: boolean
is_platform_admin: boolean
role?: UserRole;
created_at: string
}

Expand All @@ -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
Expand Down
Loading