From 05f5af5ba67113266abcd544349c9e45b34e3515 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Mon, 13 Oct 2025 15:08:51 -0300 Subject: [PATCH] Enable admin user removals and invitation UX polish --- convex/users.ts | 11 + src/app/api/admin/machines/delete/route.ts | 4 + src/app/api/admin/users/[id]/route.ts | 65 ++++++ src/components/admin/admin-users-manager.tsx | 211 +++++++++++++++++-- src/components/invite/invite-accept-form.tsx | 14 +- 5 files changed, 288 insertions(+), 17 deletions(-) diff --git a/convex/users.ts b/convex/users.ts index 414bbec..51c3c46 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -90,6 +90,17 @@ export const listAgents = query({ }, }); +export const findByEmail = query({ + args: { tenantId: v.string(), email: v.string() }, + handler: async (ctx, { tenantId, email }) => { + const record = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email)) + .first(); + return record ?? null; + }, +}); + export const deleteUser = mutation({ args: { userId: v.id("users"), actorId: v.id("users") }, handler: async (ctx, { userId, actorId }) => { diff --git a/src/app/api/admin/machines/delete/route.ts b/src/app/api/admin/machines/delete/route.ts index c25dbf7..9c56d61 100644 --- a/src/app/api/admin/machines/delete/route.ts +++ b/src/app/api/admin/machines/delete/route.ts @@ -5,6 +5,7 @@ import { ConvexHttpClient } from "convex/browser" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { api } from "@/convex/_generated/api" +import { prisma } from "@/lib/prisma" export const runtime = "nodejs" @@ -52,6 +53,9 @@ export async function POST(request: Request) { actorId, }) + const machineEmail = `machine-${parsed.data.machineId}@machines.local` + await prisma.authUser.deleteMany({ where: { email: machineEmail } }) + return NextResponse.json({ ok: true }) } catch (error) { console.error("[machines.delete] Falha ao excluir", error) diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index b7f5a38..b4503ed 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -207,3 +207,68 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id }, }) } + +export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await assertAdminSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const target = await prisma.authUser.findUnique({ + where: { id }, + select: { id: true, email: true, role: true, tenantId: true }, + }) + + if (!target) { + return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 }) + } + + if (target.role === "machine") { + return NextResponse.json({ error: "Os agentes de máquina devem ser removidos via módulo de máquinas." }, { status: 400 }) + } + + if (target.email === session.user.email) { + return NextResponse.json({ error: "Você não pode remover o usuário atualmente autenticado." }, { status: 400 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + const tenantId = target.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID + + if (convexUrl) { + try { + const convex = new ConvexHttpClient(convexUrl) + const ensured = await convex.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + + const actorId = ensured?._id + if (!actorId) { + throw new Error("Falha ao identificar o administrador no Convex") + } + + const convexUser = await convex.query(api.users.findByEmail, { + tenantId, + email: target.email, + }) + + if (convexUser?._id) { + await convex.mutation(api.users.deleteUser, { + userId: convexUser._id, + actorId, + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : "Falha ao remover usuário na base de dados" + return NextResponse.json({ error: message }, { status: 400 }) + } + } + + await prisma.authUser.delete({ where: { id: target.id } }) + + return NextResponse.json({ ok: true }) +} diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index 8191aa2..ffc0fcc 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -8,6 +8,7 @@ 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 { @@ -110,6 +111,19 @@ function formatStatus(status: AdminInvite["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 @@ -121,6 +135,11 @@ function coerceRole(role: AdminRole | string | null | undefined): RoleOption { 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 +} + export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) { const [users, setUsers] = useState(initialUsers) const [invites, setInvites] = useState(initialInvites) @@ -151,6 +170,17 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d 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 normalizedRoles = useMemo(() => { return (roleOptions && roleOptions.length > 0 ? roleOptions : ROLE_OPTIONS) as readonly AdminRole[] @@ -253,16 +283,15 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d .catch(() => toast.error("Não foi possível copiar o link")) } - async function handleRevoke(inviteId: string) { - const invite = invites.find((item) => item.id === inviteId) - if (!invite || invite.status !== "pending") return + async function handleRevokeConfirmed() { + if (!revokeCandidate || revokeCandidate.status !== "pending") { + setRevokeDialogInviteId(null) + return + } - const confirmed = window.confirm("Deseja revogar este convite?") - if (!confirmed) return - - setRevokingId(inviteId) + setRevokingId(revokeCandidate.id) try { - const response = await fetch(`/api/admin/invites/${inviteId}`, { + const response = await fetch(`/api/admin/invites/${revokeCandidate.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: "Revogado manualmente" }), @@ -282,6 +311,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d toast.error(message) } finally { setRevokingId(null) + setRevokeDialogInviteId(null) } } @@ -398,6 +428,52 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d () => [{ id: NO_COMPANY_ID, name: "Sem empresa vinculada" }, ...companies], [companies] ) + async function handleDeleteUser() { + if (!deleteTarget) 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 ( <> @@ -441,6 +517,14 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d + @@ -525,9 +609,19 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d {user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"} {formatDate(user.createdAt)} - +
+ + +
))} @@ -668,8 +762,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d {formatDate(invite.expiresAt)} {formatStatus(invite.status)} @@ -683,8 +777,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d + + + + + + { + 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.

+
+ + + + +
+
) } diff --git a/src/components/invite/invite-accept-form.tsx b/src/components/invite/invite-accept-form.tsx index 3902e01..4885769 100644 --- a/src/components/invite/invite-accept-form.tsx +++ b/src/components/invite/invite-accept-form.tsx @@ -53,6 +53,16 @@ function statusVariant(status: InviteStatus) { return "outline" } +function roleLabel(role: RoleOption) { + const labels: Record = { + admin: "Administrador", + manager: "Gestor", + agent: "Agente", + collaborator: "Colaborador", + } + return labels[role] ?? role +} + export function InviteAcceptForm({ invite }: { invite: InviteSummary }) { const router = useRouter() const [name, setName] = useState(invite.name ?? "") @@ -105,7 +115,7 @@ export function InviteAcceptForm({ invite }: { invite: InviteSummary }) { return (
- + {statusLabel(invite.status)}
@@ -113,7 +123,7 @@ export function InviteAcceptForm({ invite }: { invite: InviteSummary }) { Convite direcionado para {invite.email}

- Papel previsto: {invite.role} • Tenant: {invite.tenantId} + Acesso: {roleLabel(invite.role)} • Espaço: {invite.tenantId}

Válido até {formattedExpiry}