Enable admin user removals and invitation UX polish

This commit is contained in:
Esdras Renan 2025-10-13 15:08:51 -03:00
parent aa12ebfe0a
commit 05f5af5ba6
5 changed files with 288 additions and 17 deletions

View file

@ -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 }) => {

View file

@ -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)

View file

@ -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 })
}

View file

@ -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<AdminUser[]>(initialUsers)
const [invites, setInvites] = useState<AdminInvite[]>(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<string | null>(null)
const [deleteUserId, setDeleteUserId] = useState<string | null>(null)
const deleteTarget = useMemo(
() => users.find((candidate) => candidate.id === deleteUserId) ?? null,
[users, deleteUserId]
)
const [isDeletingUser, setIsDeletingUser] = useState(false)
const [revokeDialogInviteId, setRevokeDialogInviteId] = useState<string | null>(null)
const revokeCandidate = useMemo(
() => invites.find((invite) => invite.id === revokeDialogInviteId) ?? null,
[invites, revokeDialogInviteId]
)
const normalizedRoles = useMemo<readonly AdminRole[]>(() => {
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
<Button variant="outline" size="sm" onClick={() => setEditUserId(user.id)}>
Editar
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-500/10"
onClick={() => setDeleteUserId(user.id)}
>
Remover
</Button>
</div>
</td>
</tr>
@ -525,9 +609,19 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/admin/machines">Gerenciar</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-500/10"
onClick={() => setDeleteUserId(user.id)}
>
Remover acesso
</Button>
</div>
</td>
</tr>
))}
@ -668,8 +762,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="py-3 pr-4">
<Badge
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
variant={inviteStatusVariant(invite.status)}
className="rounded-full px-3 py-1 text-xs font-medium"
>
{formatStatus(invite.status)}
</Badge>
@ -683,8 +777,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => handleRevoke(invite.id)}
className="text-red-600 transition-colors hover:bg-red-500/10"
onClick={() => setRevokeDialogInviteId(invite.id)}
disabled={revokingId === invite.id}
>
{revokingId === invite.id ? "Revogando..." : "Revogar"}
@ -832,6 +926,93 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
) : null}
</SheetContent>
</Sheet>
<Dialog
open={deleteUserId !== null}
onOpenChange={(open) => {
if (!open) {
setDeleteUserId(null)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{deleteTarget?.role === "machine" ? "Remover agente de máquina" : "Remover colaborador"}
</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm text-neutral-600">
<p>
Confirme a exclusão de <span className="font-medium text-neutral-900">{deleteTarget?.name || deleteTarget?.email}</span>.
</p>
{deleteTarget?.role === "machine" ? (
<p>
A máquina correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.
</p>
) : (
<p>Esse usuário não poderá mais acessar o painel até receber um novo convite.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteUserId(null)} disabled={isDeletingUser}>
Cancelar
</Button>
<Button variant="destructive" onClick={handleDeleteUser} disabled={isDeletingUser}>
{isDeletingUser
? deleteTarget?.role === "machine"
? "Removendo agente..."
: "Removendo..."
: deleteTarget?.role === "machine"
? "Remover agente"
: "Remover"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={revokeDialogInviteId !== null}
onOpenChange={(open) => {
if (!open) {
setRevokeDialogInviteId(null)
setRevokingId(null)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Revogar convite</DialogTitle>
<DialogDescription>
O link atual será invalidado imediatamente. Você pode gerar um novo convite a qualquer momento.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm text-neutral-600">
<p>
Deseja revogar o convite enviado para
{" "}
<span className="font-medium text-neutral-900">{revokeCandidate?.email}</span>?
</p>
<p className="text-xs text-neutral-500">Ação irreversível o convidado verá o link expirado.</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRevokeDialogInviteId(null)} disabled={revokingId !== null}>
Manter convite
</Button>
<Button
variant="destructive"
onClick={handleRevokeConfirmed}
disabled={revokingId === revokeCandidate?.id}
>
{revokingId === revokeCandidate?.id ? "Revogando..." : "Revogar convite"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -53,6 +53,16 @@ function statusVariant(status: InviteStatus) {
return "outline"
}
function roleLabel(role: RoleOption) {
const labels: Record<RoleOption, string> = {
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 (
<div className="space-y-6">
<div className="flex flex-col items-center gap-3 text-center">
<Badge variant={statusVariant(invite.status)} className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
<Badge variant={statusVariant(invite.status)} className="rounded-full px-3 py-1 text-xs font-medium">
{statusLabel(invite.status)}
</Badge>
<div className="space-y-1">
@ -113,7 +123,7 @@ export function InviteAcceptForm({ invite }: { invite: InviteSummary }) {
Convite direcionado para <span className="font-semibold text-neutral-900">{invite.email}</span>
</p>
<p className="text-xs text-neutral-500">
Papel previsto: <span className="uppercase text-neutral-700">{invite.role}</span> Tenant: <span className="uppercase text-neutral-700">{invite.tenantId}</span>
Acesso: <span className="text-neutral-700">{roleLabel(invite.role)}</span> Espaço: <span className="text-neutral-700">{invite.tenantId}</span>
</p>
<p className="text-xs text-neutral-500">Válido até {formattedExpiry}</p>
</div>