"use client" import Link from "next/link" import { useCallback, useEffect, useMemo, useState, useTransition } from "react" import { IconSearch, IconUserPlus, IconTrash } from "@tabler/icons-react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Checkbox } from "@/components/ui/checkbox" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { useQuery } from "convex/react" import { api } from "@/convex/_generated/api" import { useAuth } from "@/lib/auth-client" import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz" import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies" type AdminRole = RoleOption | "machine" const NO_COMPANY_ID = "__none__" type AdminUser = { id: string email: string name: string role: AdminRole tenantId: string createdAt: string updatedAt: string | null companyId: string | null companyName: string | null machinePersona: string | null } type AdminInvite = { id: string email: string name: string | null role: RoleOption tenantId: string status: "pending" | "accepted" | "revoked" | "expired" token: string inviteUrl: string expiresAt: string createdAt: string createdById: string | null acceptedAt: string | null acceptedById: string | null revokedAt: string | null revokedById: string | null revokedReason: string | null } type CompanyOption = { id: string name: string } type Props = { initialUsers: AdminUser[] initialInvites: AdminInvite[] roleOptions: readonly AdminRole[] defaultTenantId: string viewerRole: string } const ROLE_LABELS: Record = { admin: "Administrador", manager: "Gestor", agent: "Agente", collaborator: "Colaborador", machine: "Agente de máquina", } function formatRole(role: string) { const key = role?.toLowerCase?.() ?? "" return ROLE_LABELS[key] ?? role } function formatMachinePersona(persona: string | null | undefined) { const normalized = persona?.toLowerCase?.() ?? "" if (normalized === "manager") return "Gestor" if (normalized === "collaborator") return "Colaborador" return "Sem persona" } function machinePersonaBadgeVariant(persona: string | null | undefined) { const normalized = persona?.toLowerCase?.() ?? "" if (normalized === "manager") return "secondary" as const if (normalized === "collaborator") return "outline" as const return "outline" as const } function formatTenantLabel(tenantId: string, defaultTenantId: string) { if (!tenantId) return "Principal" if (tenantId === defaultTenantId) return "Principal" return tenantId } function formatDate(dateIso: string) { const date = new Date(dateIso) return new Intl.DateTimeFormat("pt-BR", { dateStyle: "medium", timeStyle: "short", }).format(date) } function formatStatus(status: AdminInvite["status"]) { switch (status) { case "pending": return "Pendente" case "accepted": return "Aceito" case "revoked": return "Revogado" case "expired": return "Expirado" default: return status } } function inviteStatusVariant(status: AdminInvite["status"]) { switch (status) { case "pending": return "secondary" as const case "accepted": return "default" as const case "revoked": return "destructive" as const default: return "outline" as const } } function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite { const { events: unusedEvents, ...rest } = invite void unusedEvents return rest } function coerceRole(role: AdminRole | string | null | undefined): RoleOption { const candidate = (role ?? "agent").toLowerCase() return (ROLE_OPTIONS as readonly string[]).includes(candidate) ? (candidate as RoleOption) : "agent" } function extractMachineId(email: string): string | null { const match = /^machine-(.+)@machines\.local$/i.exec(email.trim()) return match ? match[1] : null } function isRestrictedRole(role?: string | null) { const normalized = (role ?? "").toLowerCase() return normalized === "admin" || normalized === "agent" } export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId, viewerRole }: Props) { const { convexUserId } = useAuth() const [users, setUsers] = useState(initialUsers) const [invites, setInvites] = useState(initialInvites) const [companies, setCompanies] = useState([]) const [email, setEmail] = useState("") const [name, setName] = useState("") const [role, setRole] = useState("agent") const [tenantId, setTenantId] = useState(defaultTenantId) const [expiresInDays, setExpiresInDays] = useState("7") const [lastInviteLink, setLastInviteLink] = useState(null) const [revokingId, setRevokingId] = useState(null) const [reactivatingId, setReactivatingId] = useState(null) const [isPending, startTransition] = useTransition() const [linkEmail, setLinkEmail] = useState("") const [linkCompanyId, setLinkCompanyId] = useState("") const [assigningCompany, setAssigningCompany] = useState(false) const [editUserId, setEditUserId] = useState(null) const editUser = useMemo(() => users.find((user) => user.id === editUserId) ?? null, [users, editUserId]) const [editForm, setEditForm] = useState({ name: "", email: "", role: "agent" as RoleOption, tenantId: defaultTenantId, companyId: "", }) const [isSavingUser, setIsSavingUser] = useState(false) const [isResettingPassword, setIsResettingPassword] = useState(false) const [passwordPreview, setPasswordPreview] = useState(null) const [deleteUserId, setDeleteUserId] = useState(null) const deleteTarget = useMemo( () => users.find((candidate) => candidate.id === deleteUserId) ?? null, [users, deleteUserId] ) const [isDeletingUser, setIsDeletingUser] = useState(false) const [revokeDialogInviteId, setRevokeDialogInviteId] = useState(null) const revokeCandidate = useMemo( () => invites.find((invite) => invite.id === revokeDialogInviteId) ?? null, [invites, revokeDialogInviteId] ) const viewerRoleNormalized = viewerRole?.toLowerCase?.() ?? "agent" const viewerIsAdmin = viewerRoleNormalized === "admin" const canManageUser = useCallback((role?: string | null) => viewerIsAdmin || !isRestrictedRole(role), [viewerIsAdmin]) const canManageInvite = useCallback((role: RoleOption) => viewerIsAdmin || !["admin", "agent"].includes(role), [viewerIsAdmin]) const normalizedRoles = useMemo(() => { return (roleOptions && roleOptions.length > 0 ? roleOptions : ROLE_OPTIONS) as readonly AdminRole[] }, [roleOptions]) const selectableRoles = useMemo(() => { const unique = new Set() normalizedRoles.forEach((roleOption) => { const coerced = coerceRole(roleOption) if (!viewerIsAdmin && isRestrictedRole(coerced)) return unique.add(coerced) }) return Array.from(unique) }, [normalizedRoles, viewerIsAdmin]) // Split users: team (admin/agent) and people (manager/collaborator); exclude machines const teamUsers = useMemo( () => users.filter((user) => user.role !== "machine" && ["admin", "agent"].includes(coerceRole(user.role))), [users], ) const peopleUsers = useMemo( () => users.filter((user) => user.role !== "machine" && ["manager", "collaborator"].includes(coerceRole(user.role))), [users], ) const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users]) const defaultCreateRole: RoleOption = selectableRoles[0] ?? "agent" // Equipe const [teamSearch, setTeamSearch] = useState("") const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all") const [teamCompanyFilter, setTeamCompanyFilter] = useState("all") const [teamTenantFilter, setTeamTenantFilter] = useState("all") const [teamSelection, setTeamSelection] = useState>(new Set()) const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false) const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false) // Removidos filtros antigos de Pessoas/Máquinas (agora unificado) // Unificado (pessoas + máquinas) const [usersSearch, setUsersSearch] = useState("") const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people") const [usersCompanyFilter, setUsersCompanyFilter] = useState("all") const [usersTenantFilter, setUsersTenantFilter] = useState("all") const [usersSelection, setUsersSelection] = useState>(new Set()) const [isBulkDeletingUsersCombined, setIsBulkDeletingUsersCombined] = useState(false) const [bulkDeleteUsersCombinedOpen, setBulkDeleteUsersCombinedOpen] = useState(false) const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createForm, setCreateForm] = useState({ name: "", email: "", role: defaultCreateRole, tenantId: defaultTenantId, companyId: NO_COMPANY_ID, }) const [isCreatingUser, setIsCreatingUser] = useState(false) const [createPassword, setCreatePassword] = useState(null) // Máquinas (para listar vínculos por usuário) type MachinesListItem = { id: string hostname?: string assignedUserEmail?: string | null metadata?: unknown linkedUsers?: Array<{ id: string; email: string; name: string }> } const machinesList = useQuery( convexUserId ? api.machines.listByTenant : "skip", convexUserId ? { tenantId: defaultTenantId, includeMetadata: true } : ("skip" as const) ) as MachinesListItem[] | undefined const machinesByUserEmail = useMemo(() => { const map = new Map>() ;(machinesList ?? []).forEach((m) => { const push = (email?: string | null) => { const e = (email ?? '').toLowerCase() if (!e) return const arr = map.get(e) ?? [] arr.push({ id: m.id, hostname: m.hostname }) map.set(e, arr) } push(m.assignedUserEmail) // metadata collaborator if (m.metadata && typeof m.metadata === 'object') { const rec = m.metadata as Record const c = rec['collaborator'] if (c && typeof c === 'object') { const base = c as Record if (typeof base.email === 'string') push(base.email) } } // linked users if (Array.isArray(m.linkedUsers)) { m.linkedUsers.forEach((lu) => push(lu.email)) } }) return map }, [machinesList]) // Options of tenants present in dataset for filtering const tenantOptions = useMemo(() => { const set = new Set() users.forEach((u) => u.tenantId && set.add(u.tenantId)) const list = Array.from(set) return list.length > 0 ? list : [defaultTenantId] }, [users, defaultTenantId]) const filteredTeamUsers = useMemo(() => { const term = teamSearch.trim().toLowerCase() return teamUsers.filter((user) => { if (teamCompanyFilter !== "all" && user.companyId !== teamCompanyFilter) return false if (teamTenantFilter !== "all" && (user.tenantId ?? defaultTenantId) !== teamTenantFilter) return false if (teamRoleFilter !== "all" && coerceRole(user.role) !== teamRoleFilter) return false if (!term) return true const haystack = [ user.name ?? "", user.email ?? "", user.companyName ?? "", formatRole(user.role), ] .join(" ") .toLowerCase() return haystack.includes(term) }) }, [teamUsers, teamSearch, teamRoleFilter, teamCompanyFilter, teamTenantFilter, defaultTenantId]) // Removido: lista específica de Pessoas (uso substituído pelo unificado) // Removido: filtro específico de agentes (uso substituído pelo unificado) const combinedBaseUsers = useMemo(() => { if (usersTypeFilter === "people") return peopleUsers if (usersTypeFilter === "machines") return machineUsers return [...peopleUsers, ...machineUsers] }, [peopleUsers, machineUsers, usersTypeFilter]) const filteredCombinedUsers = useMemo(() => { const term = usersSearch.trim().toLowerCase() return combinedBaseUsers.filter((user) => { if (usersCompanyFilter !== "all" && user.companyId !== usersCompanyFilter) return false if (usersTenantFilter !== "all" && (user.tenantId ?? defaultTenantId) !== usersTenantFilter) return false if (!term) return true const persona = (user.machinePersona ?? "").toLowerCase() const machineId = extractMachineId(user.email) ?? "" const haystack = [ user.name ?? "", user.email ?? "", user.companyName ?? "", formatRole(user.role), persona, machineId, ] .join(" ") .toLowerCase() return haystack.includes(term) }) }, [combinedBaseUsers, usersSearch, usersCompanyFilter, usersTenantFilter, defaultTenantId]) useEffect(() => { void (async () => { try { const response = await fetch("/api/admin/companies", { credentials: "include" }) const json = (await response.json()) as { companies?: CompanyOption[] } const mapped = (json.companies ?? []).map((company) => ({ id: company.id, name: company.name })) setCompanies(mapped) if (mapped.length > 0 && !linkCompanyId) { setLinkCompanyId(mapped[0].id) } } catch (error) { console.error("Falha ao carregar empresas", error) } })() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { if (!editUser) { setEditForm({ name: "", email: "", role: "agent", tenantId: defaultTenantId, companyId: "" }) setPasswordPreview(null) return } setEditForm({ name: editUser.name || "", email: editUser.email, role: coerceRole(editUser.role), tenantId: editUser.tenantId || defaultTenantId, companyId: editUser.companyId ?? "", }) setPasswordPreview(null) }, [editUser, defaultTenantId]) const linkedMachinesForEditUser = useMemo(() => { if (!editUser || !machinesList) return [] as Array<{ id: string; hostname?: string }> const email = (editUser.email ?? "").toLowerCase() const results: Array<{ id: string; hostname?: string }> = [] machinesList.forEach((m) => { const assigned = (m.assignedUserEmail ?? "").toLowerCase() let collaboratorEmail = "" if (m.metadata && typeof m.metadata === "object") { const rec = m.metadata as Record const c = rec["collaborator"] if (c && typeof c === "object") { const base = c as Record if (typeof base.email === "string") collaboratorEmail = base.email.toLowerCase() } } const linked = Array.isArray(m.linkedUsers) ? m.linkedUsers.some((lu) => (lu.email ?? '').toLowerCase() === email) : false if (assigned === email || (collaboratorEmail && collaboratorEmail === email) || linked) { results.push({ id: m.id, hostname: m.hostname }) } }) return results }, [editUser, machinesList]) useEffect(() => { setCreateForm((prev) => ({ ...prev, role: defaultCreateRole, tenantId: defaultTenantId, })) }, [defaultCreateRole, defaultTenantId]) async function handleInviteSubmit(event: React.FormEvent) { event.preventDefault() const normalizedEmail = email.trim().toLowerCase() if (!normalizedEmail || !normalizedEmail.includes("@")) { toast.error("Informe um e-mail válido") return } const payload = { email: normalizedEmail, name: name.trim(), role, tenantId, expiresInDays: Number.parseInt(expiresInDays, 10), } startTransition(async () => { try { const response = await fetch("/api/admin/invites", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? "Não foi possível gerar o convite") } const data = (await response.json()) as { invite: AdminInvite } const nextInvite = sanitizeInvite(data.invite) setInvites((previous) => [nextInvite, ...previous.filter((item) => item.id !== nextInvite.id)]) setEmail("") setName("") setRole("agent") setTenantId(defaultTenantId) setExpiresInDays("7") setLastInviteLink(nextInvite.inviteUrl) toast.success("Convite criado com sucesso") } catch (error) { const message = error instanceof Error ? error.message : "Falha ao criar convite" toast.error(message) } }) } function handleCopy(link: string) { navigator.clipboard .writeText(link) .then(() => toast.success("Link de convite copiado")) .catch(() => toast.error("Não foi possível copiar o link")) } async function handleRevokeConfirmed() { if (!revokeCandidate || revokeCandidate.status !== "pending") { setRevokeDialogInviteId(null) return } if (!canManageInvite(revokeCandidate.role)) { toast.error("Você não pode revogar convites deste papel") setRevokeDialogInviteId(null) return } setRevokingId(revokeCandidate.id) try { const response = await fetch(`/api/admin/invites/${revokeCandidate.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: "Revogado manualmente" }), }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? "Falha ao revogar convite") } const data = (await response.json()) as { invite: AdminInvite } const updated = sanitizeInvite(data.invite) setInvites((previous) => previous.map((item) => (item.id === updated.id ? updated : item))) toast.success("Convite revogado") } catch (error) { const message = error instanceof Error ? error.message : "Erro ao revogar convite" toast.error(message) } finally { setRevokingId(null) setRevokeDialogInviteId(null) } } async function handleReactivate(invite: AdminInvite) { if (!canReactivateInvitePolicy(invite)) return if (!canManageInvite(invite.role)) { toast.error("Você não pode reativar convites deste papel") return } setReactivatingId(invite.id) try { const response = await fetch(`/api/admin/invites/${invite.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "reactivate" }), }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? "Falha ao reativar convite") } const data = (await response.json()) as { invite: AdminInvite } const normalized = sanitizeInvite(data.invite) setInvites((previous) => previous.map((item) => (item.id === normalized.id ? normalized : item))) toast.success("Convite reativado") } catch (error) { const message = error instanceof Error ? error.message : "Não foi possível reativar" toast.error(message) } finally { setReactivatingId(null) } } async function handleAssignCompany(event: React.FormEvent) { event.preventDefault() const normalizedEmail = linkEmail.trim().toLowerCase() if (!normalizedEmail || !normalizedEmail.includes("@")) { toast.error("Informe um e-mail válido para vincular") return } if (!linkCompanyId) { toast.error("Selecione a empresa para vincular") return } setAssigningCompany(true) toast.loading("Vinculando colaborador...", { id: "assign-company" }) try { const response = await fetch("/api/admin/users/assign-company", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: normalizedEmail, companyId: linkCompanyId }), credentials: "include", }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? "Falha ao vincular") } toast.success("Colaborador vinculado com sucesso", { id: "assign-company" }) setLinkEmail("") setLinkCompanyId("") } catch (error) { const message = error instanceof Error ? error.message : "Não foi possível vincular" toast.error(message, { id: "assign-company" }) } finally { setAssigningCompany(false) } } const resetCreateForm = useCallback(() => { setCreateForm({ name: "", email: "", role: defaultCreateRole, tenantId: defaultTenantId, companyId: NO_COMPANY_ID, }) setCreatePassword(null) }, [defaultCreateRole, defaultTenantId]) const handleOpenCreateUser = useCallback(() => { resetCreateForm() setCreateDialogOpen(true) }, [resetCreateForm]) const handleCloseCreateDialog = useCallback(() => { resetCreateForm() setCreateDialogOpen(false) }, [resetCreateForm]) async function handleCreateUser(event: React.FormEvent) { event.preventDefault() const payload = { name: createForm.name.trim(), email: createForm.email.trim().toLowerCase(), role: createForm.role, tenantId: createForm.tenantId.trim() || defaultTenantId, } if (!payload.name) { toast.error("Informe o nome do usuário") return } if (!payload.email || !payload.email.includes("@")) { toast.error("Informe um e-mail válido") return } setIsCreatingUser(true) setCreatePassword(null) try { const response = await fetch("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(payload), }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? "Não foi possível criar o usuário") } const data = (await response.json()) as { user: { id: string; email: string; name: string | null; role: string; tenantId: string | null; createdAt: string }; temporaryPassword: string } const normalizedRole = coerceRole(data.user.role) const normalizedUser: AdminUser = { id: data.user.id, email: data.user.email, name: data.user.name ?? data.user.email, role: normalizedRole, tenantId: data.user.tenantId ?? defaultTenantId, createdAt: data.user.createdAt, updatedAt: null, companyId: null, companyName: null, machinePersona: null, } if (createForm.companyId && createForm.companyId !== NO_COMPANY_ID) { try { const assignResponse = await fetch("/api/admin/users/assign-company", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ email: normalizedUser.email, companyId: createForm.companyId }), }) if (assignResponse.ok) { const company = companies.find((company) => company.id === createForm.companyId) normalizedUser.companyId = createForm.companyId normalizedUser.companyName = company?.name ?? null } else { const assignData = await assignResponse.json().catch(() => ({})) toast.error(assignData.error ?? "Não foi possível vincular a empresa") } } catch (error) { const message = error instanceof Error ? error.message : "Falha ao vincular empresa" toast.error(message) } } setUsers((previous) => [normalizedUser, ...previous]) setCreatePassword(data.temporaryPassword) toast.success("Usuário criado com sucesso") setCreateForm((prev) => ({ ...prev, name: "", email: "", companyId: NO_COMPANY_ID, })) } catch (error) { const message = error instanceof Error ? error.message : "Erro ao criar usuário" toast.error(message) } finally { setIsCreatingUser(false) } } // Bulk selection helpers const selectedTeamUsers = useMemo(() => filteredTeamUsers.filter((u) => teamSelection.has(u.id)), [filteredTeamUsers, teamSelection]) const allTeamSelected = selectedTeamUsers.length > 0 && selectedTeamUsers.length === filteredTeamUsers.length const someTeamSelected = selectedTeamUsers.length > 0 && !allTeamSelected // Removido: seleção específica de Pessoas (uso substituído pelo unificado) // Removido: seleção específica de Máquinas (uso substituído pelo unificado) const [inviteSelection, setInviteSelection] = useState>(new Set()) const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection]) const allInvitesSelected = selectedInvites.length > 0 && selectedInvites.length === invites.length const someInvitesSelected = selectedInvites.length > 0 && !allInvitesSelected const [isBulkRevokingInvites, setIsBulkRevokingInvites] = useState(false) const [bulkRevokeInvitesOpen, setBulkRevokeInvitesOpen] = useState(false) function toggleTeamSelectAll(checked: boolean) { setTeamSelection(checked ? new Set(filteredTeamUsers.map((u) => u.id)) : new Set()) } // Removidos: toggles de seleção específicos (uso substituído pelo unificado) function toggleInvitesSelectAll(checked: boolean) { setInviteSelection(checked ? new Set(invites.map((i) => i.id)) : new Set()) } function toggleUsersCombinedSelectAll(checked: boolean) { setUsersSelection(checked ? new Set(filteredCombinedUsers.map((u) => u.id)) : new Set()) } async function performBulkDeleteUsers(ids: string[]) { if (ids.length === 0) return const tasks = ids.map(async (id) => { const user = users.find((u) => u.id === id) if (!user) return if (!canManageUser(user.role)) return const response = await fetch(`/api/admin/users/${id}`, { method: "DELETE", credentials: "include" }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? `Falha ao remover ${user.email}`) } }) await Promise.allSettled(tasks) setUsers((prev) => prev.filter((u) => !ids.includes(u.id))) } async function performBulkDeleteMachines(ids: string[]) { if (ids.length === 0) return const tasks = ids.map(async (id) => { const user = users.find((u) => u.id === id) if (!user) return const machineId = extractMachineId(user.email) if (!machineId) return const response = await fetch(`/api/admin/machines/delete`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ machineId }), }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? `Falha ao remover agente ${user.email}`) } }) await Promise.allSettled(tasks) setUsers((prev) => prev.filter((u) => !ids.includes(u.id))) } async function performBulkRevokeInvites(ids: string[]) { if (ids.length === 0) return const tasks = ids.map(async (id) => { const invite = invites.find((i) => i.id === id) if (!invite) return if (!canManageInvite(invite.role)) return const response = await fetch(`/api/admin/invites/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: "Revogado em massa" }), }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? `Falha ao revogar ${invite.email}`) } }) await Promise.allSettled(tasks) setInvites((prev) => prev.map((i) => (ids.includes(i.id) && i.status === "pending" ? { ...i, status: "revoked", revokedAt: new Date().toISOString() } : i))) } async function performBulkDeleteUsersCombined(ids: string[]) { if (ids.length === 0) return const machineIds: string[] = [] const humanIds: string[] = [] ids.forEach((id) => { const u = users.find((x) => x.id === id) if (!u) return if (u.role === "machine") machineIds.push(id) else humanIds.push(id) }) await Promise.allSettled([ performBulkDeleteMachines(machineIds), performBulkDeleteUsers(humanIds), ]) } async function handleSaveUser(event: React.FormEvent) { event.preventDefault() if (!editUser) return const payload = { name: editForm.name.trim(), email: editForm.email.trim().toLowerCase(), role: editForm.role, tenantId: editForm.tenantId.trim() || defaultTenantId, companyId: editForm.companyId || null, } if (!payload.name) { toast.error("Informe o nome do usuário") return } if (!payload.email || !payload.email.includes("@")) { toast.error("Informe um e-mail válido") return } setIsSavingUser(true) try { const response = await fetch(`/api/admin/users/${editUser.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(payload), }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? "Não foi possível atualizar o usuário") } const data = (await response.json()) as { user: AdminUser } setUsers((previous) => previous.map((item) => (item.id === data.user.id ? data.user : item))) toast.success("Usuário atualizado com sucesso") setEditUserId(null) } catch (error) { const message = error instanceof Error ? error.message : "Erro ao salvar alterações" toast.error(message) } finally { setIsSavingUser(false) } } async function handleResetPassword() { if (!editUser) return if (!canManageUser(editUser.role)) { toast.error("Você não pode gerar senha para este usuário") return } setIsResettingPassword(true) toast.loading("Gerando nova senha...", { id: "reset-password" }) try { const response = await fetch(`/api/admin/users/${editUser.id}/reset-password`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? "Falha ao gerar nova senha") } const data = (await response.json()) as { temporaryPassword: string } setPasswordPreview(data.temporaryPassword) toast.success("Senha temporária criada", { id: "reset-password" }) } catch (error) { const message = error instanceof Error ? error.message : "Erro ao gerar senha" toast.error(message, { id: "reset-password" }) } finally { setIsResettingPassword(false) } } const isMachineEditing = editUser?.role === "machine" const editingRestricted = editUser ? !canManageUser(editUser.role) : false const companyOptions = useMemo( () => [{ id: NO_COMPANY_ID, name: "Sem empresa vinculada" }, ...companies], [companies] ) async function handleDeleteUser() { if (!deleteTarget) return if (!canManageUser(deleteTarget.role)) { toast.error("Você não pode remover esse usuário") setDeleteUserId(null) return } setIsDeletingUser(true) const isMachine = deleteTarget.role === "machine" try { if (isMachine) { const machineId = extractMachineId(deleteTarget.email) if (!machineId) { throw new Error("Não foi possível identificar a máquina associada.") } const response = await fetch("/api/admin/machines/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ machineId }), credentials: "include", }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? "Falha ao remover agente de máquina") } toast.success("Agente de máquina removido") } else { const response = await fetch(`/api/admin/users/${deleteTarget.id}`, { method: "DELETE", credentials: "include", }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.error ?? "Falha ao remover colaborador") } toast.success("Colaborador removido") } setUsers((previous) => previous.filter((user) => user.id !== deleteTarget.id)) if (editUserId === deleteTarget.id) { setEditUserId(null) } } catch (error) { const message = error instanceof Error ? error.message : "Não foi possível remover o usuário" toast.error(message) } finally { setIsDeletingUser(false) setDeleteUserId(null) } } return ( <> Equipe Usuários Convites

Equipe cadastrada

{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "membro" : "membros"}

setTeamSearch(event.target.value)} placeholder="Buscar por nome, e-mail ou empresa..." className="h-9 pl-9" />
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? ( ) : null}
Equipe cadastrada Usuários com acesso ao sistema. Edite papéis, dados pessoais ou gere uma nova senha. {filteredTeamUsers.map((user) => ( ))} {filteredTeamUsers.length === 0 ? ( ) : null}
toggleTeamSelectAll(!!value)} aria-label="Selecionar todos" />
Nome E-mail Papel Empresa Máquinas Espaço Criado em Ações
{ setTeamSelection((prev) => { const next = new Set(prev) if (checked) next.add(user.id) else next.delete(user.id) return next }) }} aria-label="Selecionar linha" />
{user.name || "—"} {user.email} {formatRole(user.role)} {user.companyName ?? "—"} {(() => { const list = machinesByUserEmail.get((user.email ?? '').toLowerCase()) ?? [] return list.length > 0 ? ( {list.length} {list.length === 1 ? 'máquina' : 'máquinas'} ) : '—' })()} {formatTenantLabel(user.tenantId, defaultTenantId)} {formatDate(user.createdAt)}
{teamUsers.length === 0 ? "Nenhum usuário cadastrado até o momento." : "Nenhum usuário corresponde aos filtros atuais."}
Vincular usuário a empresa Associe colaboradores existentes a uma empresa para liberar painéis de gestores e filtros específicos.
setLinkEmail(event.target.value)} placeholder="colaborador@empresa.com" type="email" required />

Caso a empresa ainda não exista, cadastre-a em Admin ▸ Empresas & clientes.

Usuários

{filteredCombinedUsers.length} {filteredCombinedUsers.length === 1 ? "usuário" : "usuários"}

setUsersSearch(event.target.value)} placeholder="Buscar por nome, e-mail, empresa ou máquina..." className="h-9 pl-9" />
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all" || usersTenantFilter !== "all") ? ( ) : null}
Usuários Pessoas e máquinas com acesso ao sistema. {filteredCombinedUsers.map((user) => ( ))} {filteredCombinedUsers.length === 0 ? ( ) : null}
0 && usersSelection.size === filteredCombinedUsers.length || (usersSelection.size > 0 && usersSelection.size < filteredCombinedUsers.length && "indeterminate")} onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)} aria-label="Selecionar todos" />
Nome E-mail Tipo Perfil Empresa Espaço Criado em Ações
{ setUsersSelection((prev) => { const next = new Set(prev) if (checked) next.add(user.id) else next.delete(user.id) return next }) }} aria-label="Selecionar linha" />
{user.name || (user.role === "machine" ? "Máquina" : "—")} {user.email} {user.role === "machine" ? "Máquina" : "Pessoa"} {user.role === "machine" ? ( user.machinePersona ? ( {formatMachinePersona(user.machinePersona)} ) : ( Sem persona ) ) : ( formatRole(user.role) )} {user.companyName ?? "—"} {formatTenantLabel(user.tenantId, defaultTenantId)} {formatDate(user.createdAt)}
{user.role === "machine" ? ( ) : null}
{combinedBaseUsers.length === 0 ? "Nenhum usuário cadastrado até o momento." : "Nenhum usuário corresponde aos filtros atuais."}
Gerar convite Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
setEmail(event.target.value)} required autoComplete="off" />
setName(event.target.value)} autoComplete="off" />
{lastInviteLink ? (

Link de convite pronto

Compartilhe com o convidado. O link expira automaticamente no prazo selecionado.

{lastInviteLink}
) : null}
Convites emitidos Histórico e status atual de todos os convites enviados para o workspace. {invites.map((invite) => ( ))} {invites.length === 0 ? ( ) : null}
toggleInvitesSelectAll(!!value)} aria-label="Selecionar todos" />
Colaborador Papel Espaço Expira em Status Ações
{ setInviteSelection((prev) => { const next = new Set(prev) if (checked) next.add(invite.id) else next.delete(invite.id) return next }) }} aria-label="Selecionar linha" />
{invite.name || invite.email} {invite.email}
{formatRole(invite.role)} {formatTenantLabel(invite.tenantId, defaultTenantId)} {formatDate(invite.expiresAt)} {formatStatus(invite.status)}
{invite.status === "pending" && canManageInvite(invite.role) ? ( ) : null} {invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? ( ) : null}
Nenhum convite emitido até o momento.
{ if (open) { setCreateDialogOpen(true) } else { handleCloseCreateDialog() } }} > Novo usuário Crie uma conta para membros da equipe com acesso imediato ao sistema.
setCreateForm((prev) => ({ ...prev, name: event.target.value }))} required autoComplete="off" />
setCreateForm((prev) => ({ ...prev, email: event.target.value }))} required autoComplete="off" />
setCreateForm((prev) => ({ ...prev, tenantId: event.target.value }))} />
{createPassword ? (

Senha temporária gerada:

{createPassword}
) : null}
Remover usuários selecionados Pessoas perderão o acesso e máquinas serão desconectadas.
{Array.from(usersSelection).slice(0, 5).map((id) => { const u = users.find((x) => x.id === id) if (!u) return null return (
{(u.name || u.email)} — {u.email}
) })} {usersSelection.size > 5 ? (
+ {usersSelection.size - 5} outros
) : null}
(!open ? setEditUserId(null) : null)}> Editar usuário Atualize os dados cadastrais, papel e vínculo do colaborador. {editUser ? (
{editingRestricted ? (
Você pode visualizar este perfil, mas apenas administradores podem alterá-lo.
) : null}
setEditForm((prev) => ({ ...prev, name: event.target.value }))} placeholder="Nome completo" disabled={isSavingUser || isMachineEditing || editingRestricted} required />
setEditForm((prev) => ({ ...prev, email: event.target.value }))} type="email" disabled={isSavingUser || isMachineEditing || editingRestricted} required />
setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))} placeholder="tenant-atlas" disabled={isSavingUser || isMachineEditing || editingRestricted} />

Essa seleção substitui o vínculo atual no portal do cliente.

{linkedMachinesForEditUser.length > 0 ? (
    {linkedMachinesForEditUser.map((m) => (
  • {m.hostname || m.id}
  • ))}
) : (

Nenhuma máquina vinculada a este usuário.

)}
{isMachineEditing ? (
Os ajustes detalhados de agentes de máquina são feitos em Admin ▸ Máquinas.
) : (

Gerar nova senha

Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.

{passwordPreview ? (
{passwordPreview}
) : null}
)}
) : null}
{ if (!open) { setDeleteUserId(null) } }} > {deleteTarget?.role === "machine" ? "Remover agente de máquina" : "Remover colaborador"} {deleteTarget?.role === "machine" ? "Revogar o acesso desconecta o aplicativo desktop e exige novo provisionamento." : "A remoção impede novos acessos, mas não afeta registros históricos de tickets."}

Confirme a exclusão de {deleteTarget?.name || deleteTarget?.email}.

{deleteTarget?.role === "machine" ? (

A máquina correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.

) : (

Esse usuário não poderá mais acessar o painel até receber um novo convite.

)}
{ if (!open) { setRevokeDialogInviteId(null) setRevokingId(null) } }} > Revogar convite O link atual será invalidado imediatamente. Você pode gerar um novo convite a qualquer momento.

Deseja revogar o convite enviado para {" "} {revokeCandidate?.email}?

Ação irreversível — o convidado verá o link expirado.

{/* Bulk actions */} Remover membros selecionados Revoga o acesso imediatamente. Registros históricos permanecem.
{selectedTeamUsers.slice(0, 5).map((u) => (
{u.name || u.email} — {u.email}
))} {selectedTeamUsers.length > 5 ? (
+ {selectedTeamUsers.length - 5} outros
) : null}
{/* Dialogs antigos removidos: ações em massa agora são unificadas no diálogo abaixo */} Revogar convites selecionados Os links serão invalidados imediatamente.
{selectedInvites.slice(0, 5).map((i) => (
{i.email} — {formatRole(i.role)}
))} {selectedInvites.length > 5 ? (
+ {selectedInvites.length - 5} outros
) : null}
) }