Fix profile 404/500 and user deletion 403

This commit is contained in:
Tiago Yamamoto 2025-12-26 09:55:19 -03:00
parent f51a8dd99c
commit e0b16e5b29
6 changed files with 224 additions and 45 deletions

View file

@ -366,8 +366,19 @@ func (h *CoreHandlers) ListUsers(w http.ResponseWriter, r *http.Request) {
// @Router /api/v1/users/{id} [delete]
func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tenantID, ok := ctx.Value(middleware.ContextTenantID).(string)
if !ok || tenantID == "" {
tenantID, _ := ctx.Value(middleware.ContextTenantID).(string)
// Check for admin role to bypass tenant check
userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles))
isAdmin := false
for _, role := range userRoles {
if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" {
isAdmin = true
break
}
}
if !isAdmin && tenantID == "" {
http.Error(w, "Tenant ID not found in context", http.StatusForbidden)
return
}
@ -380,7 +391,18 @@ func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.deleteUserUC.Execute(ctx, id, tenantID); err != nil {
// If admin, we pass empty tenantID to signal bypass to the usecase (or specific admin logic)
// But wait, UpdateUserUseCase treats empty tenantID as bypass. Let's see DeleteUserUseCase.
// We need to match that logic.
targetTenantID := tenantID
if isAdmin {
targetTenantID = "" // Signal bypass
}
log.Printf("[DeleteUser] UserID: %s, RequestID: %s, IsAdmin: %v, TenantID: %s, TargetTenantID: %s", r.PathValue("id"), middleware.GetRequestID(r.Context()), isAdmin, tenantID, targetTenantID)
if err := h.deleteUserUC.Execute(ctx, id, targetTenantID); err != nil {
log.Printf("[DeleteUser] Error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@ -750,8 +772,10 @@ func (h *CoreHandlers) UpdateMyProfile(w http.ResponseWriter, r *http.Request) {
}
// userID (string) passed directly as first arg
log.Printf("[UpdateMyProfile] UserID: %s, TenantID: %s", userID, tenantID)
resp, err := h.updateUserUC.Execute(ctx, userID, tenantID, req)
if err != nil {
log.Printf("[UpdateMyProfile] Error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@ -835,13 +859,16 @@ func (h *CoreHandlers) Me(w http.ResponseWriter, r *http.Request) {
userID = strconv.Itoa(int(v))
}
log.Printf("[Me Handler] Processing request for UserID: %s", userID)
user, err := h.adminService.GetUser(ctx, userID)
if err != nil {
log.Printf("ERROR [Me Handler] GetUser failed for userID %s: %v", userID, err)
log.Printf("[Me Handler] GetUser failed for userID %s: %v", userID, err)
// Check for specific error types if possible, or just return 500
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("SUCCESS [Me Handler] User retrieved: %s", user.Email)
log.Printf("[Me Handler] User retrieved: %s", user.Email)
company, _ := h.adminService.GetCompanyByUserID(ctx, userID)
if company != nil {

View file

@ -24,10 +24,12 @@ func (uc *DeleteUserUseCase) Execute(ctx context.Context, userID, tenantID strin
return err
}
if user.TenantID != tenantID {
// 2. Check Permission (Tenant Check)
// If tenantID is empty, it means SuperAdmin or Admin (handler logic), so we skip check.
if tenantID != "" && user.TenantID != tenantID {
return errors.New("user not found in this tenant")
}
// 2. Delete
// 3. Delete
return uc.userRepo.Delete(ctx, userID)
}

View file

@ -157,6 +157,7 @@ func NewRouter() http.Handler {
// Public /api/v1/users/me (Authenticated)
mux.Handle("GET /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.Me)))
mux.Handle("PATCH /api/v1/users/me/profile", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMyProfile)))
// /api/v1/admin/companies -> Handled by coreHandlers.ListCompanies (Smart Branching)
// Needs to be wired with Optional Auth to support both Public and Admin.

View file

@ -18,6 +18,7 @@ import {
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle, Eye, Trash2, Pencil } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { adminCompaniesApi, type AdminCompany } from "@/lib/api"
import { getCurrentUser, isAdminUser } from "@/lib/auth"
import { toast } from "sonner"
@ -82,6 +83,8 @@ export default function AdminCompaniesPage() {
document: "",
address: "",
description: "",
active: false,
verified: false,
})
useEffect(() => {
@ -184,6 +187,8 @@ export default function AdminCompaniesPage() {
document: company.document || "",
address: company.address || "",
description: company.description || "",
active: company.active || false,
verified: company.verified || false,
})
setIsEditDialogOpen(true)
setIsViewDialogOpen(false)
@ -193,7 +198,26 @@ export default function AdminCompaniesPage() {
if (!selectedCompany) return
try {
setUpdating(true)
await adminCompaniesApi.update(selectedCompany.id, editFormData)
// Check if status changed
if (editFormData.active !== selectedCompany.active || editFormData.verified !== selectedCompany.verified) {
await adminCompaniesApi.updateStatus(selectedCompany.id, {
active: editFormData.active,
verified: editFormData.verified
})
}
await adminCompaniesApi.update(selectedCompany.id, {
name: editFormData.name,
slug: editFormData.slug,
email: editFormData.email,
phone: editFormData.phone,
website: editFormData.website,
document: editFormData.document,
address: editFormData.address,
description: editFormData.description,
})
toast.success("Company updated successfully")
setIsEditDialogOpen(false)
loadCompanies()
@ -434,7 +458,7 @@ export default function AdminCompaniesPage() {
</Card>
{/* View Company Modal */}
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
@ -561,6 +585,24 @@ export default function AdminCompaniesPage() {
<DialogDescription>Update company information</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex items-center gap-4 border p-4 rounded-md">
<div className="flex items-center gap-2">
<Switch
checked={editFormData.active}
onCheckedChange={(checked) => setEditFormData({ ...editFormData, active: checked })}
id="edit-active"
/>
<Label htmlFor="edit-active">Active</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={editFormData.verified}
onCheckedChange={(checked) => setEditFormData({ ...editFormData, verified: checked })}
id="edit-verified"
/>
<Label htmlFor="edit-verified">Verified</Label>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-name">Company name</Label>
<Input

View file

@ -28,8 +28,10 @@ export default function ProfilePage() {
}, [])
const loadProfile = async () => {
console.log("[PROFILE_FLOW] Loading profile...")
try {
const userData = await authApi.getCurrentUser()
console.log("[PROFILE_FLOW] Profile loaded:", userData)
setUser(userData)
setFormData({
fullName: userData.fullName || "",
@ -38,6 +40,7 @@ export default function ProfilePage() {
bio: userData.bio || ""
})
} catch (error) {
console.error("[PROFILE_FLOW] Error loading profile:", error)
toast.error("Failed to load profile")
} finally {
setLoading(false)
@ -51,14 +54,17 @@ export default function ProfilePage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
console.log("[PROFILE_FLOW] Updating profile:", formData)
try {
await profileApi.update({
name: formData.fullName,
phone: formData.phone,
bio: formData.bio
})
console.log("[PROFILE_FLOW] Profile updated successfully")
toast.success("Profile updated")
} catch (error) {
console.error("[PROFILE_FLOW] Error updating profile:", error)
toast.error("Failed to update profile")
} finally {
setSaving(false)

View file

@ -18,7 +18,7 @@ import {
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Plus, Search, Trash2, Loader2, RefreshCw, Pencil } from "lucide-react"
import { Plus, Search, Trash2, Loader2, RefreshCw, Pencil, Eye } from "lucide-react"
import { usersApi, adminCompaniesApi, type ApiUser, type AdminCompany } from "@/lib/api"
import { getCurrentUser, isAdminUser } from "@/lib/auth"
import { toast } from "sonner"
@ -36,13 +36,23 @@ export default function AdminUsersPage() {
const [searchTerm, setSearchTerm] = useState("")
const [page, setPage] = useState(1)
const [totalUsers, setTotalUsers] = useState(0)
// Dialog States
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
// Action States
const [creating, setCreating] = useState(false)
const [updating, setUpdating] = useState(false)
const [deleting, setDeleting] = useState(false)
const [viewing, setViewing] = useState(false)
const [selectedUser, setSelectedUser] = useState<ApiUser | null>(null)
const [companies, setCompanies] = useState<AdminCompany[]>([])
const [currentUser, setCurrentUser] = useState<ApiUser | null>(null)
// Form Data
const [formData, setFormData] = useState({
name: "",
email: "",
@ -64,7 +74,7 @@ export default function AdminUsersPage() {
router.push("/dashboard")
return
}
setCurrentUser(user as ApiUser) // Casting safe here due to check
setCurrentUser(user as ApiUser)
loadUsers()
if (user?.role === 'superadmin') {
@ -85,14 +95,16 @@ export default function AdminUsersPage() {
const totalPages = Math.max(1, Math.ceil(totalUsers / limit))
const loadUsers = async (targetPage = page) => {
console.log(`[USER_FLOW] Loading users page: ${targetPage}`)
try {
setLoading(true)
const data = await usersApi.list({ page: targetPage, limit })
console.log("[USER_FLOW] Users loaded:", data)
setUsers(data?.data || [])
setTotalUsers(data?.pagination?.total || 0)
setPage(data?.pagination?.page || targetPage)
} catch (error) {
console.error("Error loading users:", error)
console.error("[USER_FLOW] Error loading users:", error)
toast.error("Failed to load users")
} finally {
setLoading(false)
@ -100,11 +112,12 @@ export default function AdminUsersPage() {
}
const handleCreate = async () => {
console.log("[USER_FLOW] Creating user with data:", formData)
try {
setCreating(true)
const payload = {
...formData,
roles: [formData.role], // Backend expects array
roles: [formData.role],
}
await usersApi.create(payload)
toast.success("User created successfully!")
@ -113,14 +126,15 @@ export default function AdminUsersPage() {
setPage(1)
loadUsers(1)
} catch (error) {
console.error("Error creating user:", error)
console.error("[USER_FLOW] Error creating user:", error)
toast.error("Failed to create user")
} finally {
setCreating(false)
}
}
const handleEdit = (user: ApiUser) => {
const handleView = (user: ApiUser) => {
console.log("[USER_FLOW] Viewing user:", user)
setSelectedUser(user)
setEditFormData({
name: user.name,
@ -128,54 +142,78 @@ export default function AdminUsersPage() {
role: user.role,
status: user.status || "active",
})
setViewing(true)
setIsEditDialogOpen(true)
}
const handleEdit = (user: ApiUser) => {
console.log("[USER_FLOW] Editing user:", user)
setSelectedUser(user)
setEditFormData({
name: user.name,
email: user.email,
role: user.role,
status: user.status || "active",
})
setViewing(false)
setIsEditDialogOpen(true)
}
const handleUpdate = async () => {
if (!selectedUser) return
console.log("[USER_FLOW] Updating user:", selectedUser.id, "with data:", editFormData)
try {
setUpdating(true)
const payload = {
...editFormData,
roles: [editFormData.role], // Backend expects array of roles
roles: [editFormData.role],
}
await usersApi.update(selectedUser.id, payload)
toast.success("User updated successfully!")
setIsEditDialogOpen(false)
loadUsers() // Refresh list
loadUsers()
} catch (error) {
console.error("Error updating user:", error)
console.error("[USER_FLOW] Error updating user:", error)
toast.error("Failed to update user")
} finally {
setUpdating(false)
}
}
const handleDelete = async (id: string) => {
// Optimistic UI update or wait? User asked for no full reload.
// We can remove it from state immediately.
// We should use a proper dialog but standard confirm is quick.
if (!confirm("Are you sure you want to delete this user?")) return
const handleDeleteClick = (user: ApiUser) => {
console.log("[USER_FLOW] Delete click for user:", user)
setSelectedUser(user)
setIsDeleteDialogOpen(true)
}
// Optimistic update
const originalUsers = [...users]
setUsers(users.filter(u => u.id !== id))
const confirmDelete = async () => {
if (!selectedUser) return
console.log("[USER_FLOW] Confirming delete for user:", selectedUser.id)
try {
await usersApi.delete(id)
setDeleting(true)
await usersApi.delete(selectedUser.id)
toast.success("User deleted!")
// If we are on a page > 1 and it becomes empty, we might need to fetch prev page
// UI Update logic
if (users.length === 1 && page > 1) {
setPage(page - 1)
loadUsers(page - 1)
} else {
// Background revalidate to ensure count is correct
loadUsers(page)
}
} catch (error) {
console.error("Error deleting user:", error)
toast.error("Failed to delete user")
setUsers(originalUsers) // Revert on failure
setIsDeleteDialogOpen(false)
setSelectedUser(null)
} catch (error: any) {
console.error("[USER_FLOW] Error deleting user:", error)
// Error handling matching user request to see logs
if (error.message && error.message.includes("403")) {
toast.error("You don't have permission to delete this user (403)")
} else {
toast.error("Failed to delete user")
}
} finally {
setDeleting(false)
}
}
@ -312,11 +350,13 @@ export default function AdminUsersPage() {
</DialogContent>
</Dialog>
</div>
{/* Edit / View Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>Update user details</DialogDescription>
<DialogTitle>{viewing ? "User Details" : "Edit User"}</DialogTitle>
<DialogDescription>{viewing ? "View user information" : "Update user details"}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
@ -326,6 +366,8 @@ export default function AdminUsersPage() {
value={editFormData.name}
onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })}
placeholder="Full name"
readOnly={viewing}
disabled={viewing}
/>
</div>
<div className="grid gap-2">
@ -336,11 +378,17 @@ export default function AdminUsersPage() {
value={editFormData.email}
onChange={(e) => setEditFormData({ ...editFormData, email: e.target.value })}
placeholder="email@example.com"
readOnly={viewing}
disabled={viewing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-role">Role</Label>
<Select value={editFormData.role} onValueChange={(v) => setEditFormData({ ...editFormData, role: v })}>
<Select
value={editFormData.role}
onValueChange={(v) => setEditFormData({ ...editFormData, role: v })}
disabled={viewing}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@ -354,7 +402,11 @@ export default function AdminUsersPage() {
</div>
<div className="grid gap-2">
<Label htmlFor="edit-status">Status</Label>
<Select value={editFormData.status} onValueChange={(v) => setEditFormData({ ...editFormData, status: v })}>
<Select
value={editFormData.status}
onValueChange={(v) => setEditFormData({ ...editFormData, status: v })}
disabled={viewing}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@ -366,10 +418,44 @@ export default function AdminUsersPage() {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>Cancel</Button>
<Button onClick={handleUpdate} disabled={updating}>
{updating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save Changes
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
{viewing ? "Close" : "Cancel"}
</Button>
{!viewing && (
<Button onClick={handleUpdate} disabled={updating}>
{updating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save Changes
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
Are you sure you want to delete this user? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{selectedUser && (
<div className="text-sm border p-4 rounded-md bg-muted/50">
<p><strong>Name:</strong> {selectedUser.name}</p>
<p><strong>Email:</strong> {selectedUser.email}</p>
<p><strong>ID:</strong> {selectedUser.id}</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Delete User
</Button>
</DialogFooter>
</DialogContent>
@ -458,8 +544,15 @@ export default function AdminUsersPage() {
<TableCell>{user.email}</TableCell>
<TableCell>{getRoleBadge(user.role)}</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "secondary"}>
{user.status === "active" ? "Active" : user.status}
<Badge
variant="outline"
className={
user.status === "active"
? "border-transparent bg-green-500/15 text-green-700 hover:bg-green-500/25 dark:text-green-400"
: "border-transparent bg-red-500/15 text-red-700 hover:bg-red-500/25 dark:text-red-400"
}
>
{user.status ? user.status.toUpperCase() : "UNKNOWN"}
</Badge>
</TableCell>
<TableCell>
@ -467,6 +560,14 @@ export default function AdminUsersPage() {
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleView(user)}
title="View Details"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@ -478,7 +579,7 @@ export default function AdminUsersPage() {
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.id)}
onClick={() => handleDeleteClick(user)}
disabled={user.role === "superadmin"}
>
<Trash2 className="h-4 w-4 text-destructive" />