feat: add agent reset flow and document machine handover

This commit is contained in:
codex-bot 2025-11-03 15:16:34 -03:00
parent 28796bf105
commit 25d2a9b062
6 changed files with 196 additions and 8 deletions

View file

@ -0,0 +1,60 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { ConvexHttpClient } from "convex/browser"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { api } from "@/convex/_generated/api"
export const runtime = "nodejs"
const schema = z.object({
machineId: z.string().min(1),
})
export async function POST(request: Request) {
const session = await assertAuthenticatedSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
}
const payload = await request.json().catch(() => null)
const parsed = schema.safeParse(payload)
if (!parsed.success) {
return NextResponse.json({ error: "Payload inválido", details: parsed.error.flatten() }, { status: 400 })
}
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
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) {
return NextResponse.json({ error: "Falha ao obter ID do usuário no Convex" }, { status: 500 })
}
const client = convex as unknown as { mutation: (name: string, args: unknown) => Promise<unknown> }
const result = (await client.mutation("machines:resetAgent", {
machineId: parsed.data.machineId,
actorId,
})) as { revoked?: number } | null
return NextResponse.json({ ok: true, revoked: result?.revoked ?? 0 })
} catch (error) {
console.error("[machines.resetAgent] Falha ao resetar agente", error)
return NextResponse.json({ error: "Falha ao resetar agente da máquina" }, { status: 500 })
}
}

View file

@ -2280,6 +2280,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
[editingRemoteAccessClientId, remoteAccessEntries]
)
const [togglingActive, setTogglingActive] = useState(false)
const [isResettingAgent, setIsResettingAgent] = useState(false)
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
const jsonText = useMemo(() => {
const payload = {
@ -2569,6 +2570,34 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}
}
const handleResetAgent = useCallback(async () => {
if (!machine) return
toast.dismiss("machine-reset")
toast.loading("Resetando agente...", { id: "machine-reset" })
setIsResettingAgent(true)
try {
const response = await fetch("/api/admin/machines/reset-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ machineId: machine.id }),
})
const payload = (await response.json().catch(() => null)) as { error?: string; revoked?: number } | null
if (!response.ok) {
const message = payload?.error ?? "Falha ao resetar agente."
throw new Error(message)
}
const revokedLabel = typeof payload?.revoked === "number" && payload.revoked > 0 ? ` (${payload.revoked} token(s) revogados)` : ""
toast.success(`Agente resetado${revokedLabel}. Reprovisione o agente na máquina.`, { id: "machine-reset" })
router.refresh()
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao resetar agente."
toast.error(message, { id: "machine-reset" })
} finally {
setIsResettingAgent(false)
}
}, [machine, router])
const handleCopyRemoteIdentifier = useCallback(async (identifier: string | null | undefined) => {
if (!identifier) return
try {
@ -2702,6 +2731,16 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<ShieldCheck className="size-4" />
Ajustar acesso
</Button>
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:text-amber-800"
onClick={handleResetAgent}
disabled={isResettingAgent}
>
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
</Button>
<Button
size="sm"
variant={isActiveLocal ? "outline" : "default"}

View file

@ -1,6 +1,6 @@
"use client"
import { Suspense, type ReactNode } from "react"
import { Suspense, type ReactNode, useEffect, useState } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth/auth-guard"
@ -15,6 +15,14 @@ interface AppShellProps {
export function AppShell({ header, children }: AppShellProps) {
const { isLoading } = useAuth()
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
}, [])
const renderSkeleton = !hydrated || isLoading
return (
<SidebarProvider>
<AppSidebar />
@ -22,7 +30,7 @@ export function AppShell({ header, children }: AppShellProps) {
<Suspense fallback={null}>
<AuthGuard />
</Suspense>
{isLoading ? (
{renderSkeleton ? (
<header className="flex h-auto shrink-0 flex-wrap items-start gap-3 border-b bg-background/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 transition-[width,height] ease-linear sm:h-(--header-height) sm:flex-nowrap sm:items-center sm:px-6 lg:px-8 sm:group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-4 w-52" />
@ -37,7 +45,7 @@ export function AppShell({ header, children }: AppShellProps) {
header
)}
<main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6">
{isLoading ? (
{renderSkeleton ? (
<div className="space-y-6">
<div className="px-4 lg:px-6">
<div className="grid gap-6 lg:grid-cols-2">