feat: add agent reset flow and document machine handover
This commit is contained in:
parent
28796bf105
commit
25d2a9b062
6 changed files with 196 additions and 8 deletions
21
README.md
21
README.md
|
|
@ -1,11 +1,11 @@
|
||||||
## Sistema de Chamados
|
## Sistema de Chamados
|
||||||
|
|
||||||
Aplicação Next.js 15 com Convex e Better Auth para gestão de tickets da Rever. Todo o código-fonte está organizado diretamente na raiz do repositório, conforme convenções do Next.js.
|
Aplicação **Next.js 16 (App Router)** com **React 19**, **Convex** e **Better Auth** para gestão de tickets da Rever. A stack ainda inclui **Prisma 6** (SQLite padrão para DEV), **Tailwind** e **Turbopack** como bundler padrão. Todo o código-fonte fica na raiz do monorepo seguindo as convenções do App Router.
|
||||||
|
|
||||||
## Requisitos
|
## Requisitos
|
||||||
|
|
||||||
- Node.js >= 20
|
- Node.js >= 20
|
||||||
- pnpm >= 8
|
- pnpm >= 9 (habilite via `corepack prepare pnpm@9 --activate`)
|
||||||
- CLI do Convex (`pnpm dlx convex dev` instalará automaticamente no primeiro uso)
|
- CLI do Convex (`pnpm dlx convex dev` instalará automaticamente no primeiro uso)
|
||||||
|
|
||||||
## Configuração rápida
|
## Configuração rápida
|
||||||
|
|
@ -34,7 +34,7 @@ Aplicação Next.js 15 com Convex e Better Auth para gestão de tickets da Rever
|
||||||
```bash
|
```bash
|
||||||
pnpm convex:dev
|
pnpm convex:dev
|
||||||
```
|
```
|
||||||
7. Em outro terminal, suba o frontend Next.js:
|
7. Em outro terminal, suba o frontend Next.js (Turbopack):
|
||||||
```bash
|
```bash
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
@ -44,7 +44,7 @@ Aplicação Next.js 15 com Convex e Better Auth para gestão de tickets da Rever
|
||||||
|
|
||||||
### Documentação
|
### Documentação
|
||||||
- Índice de docs: `docs/README.md`
|
- Índice de docs: `docs/README.md`
|
||||||
- Operações (produção): `docs/operations.md`
|
- Operações (produção): `docs/OPERATIONS.md` (versão EN) e `docs/OPERACAO-PRODUCAO.md` (PT-BR)
|
||||||
- Guia de DEV: `docs/DEV.md`
|
- Guia de DEV: `docs/DEV.md`
|
||||||
- Testes automatizados (Vitest/Playwright): `docs/testes-vitest.md`
|
- Testes automatizados (Vitest/Playwright): `docs/testes-vitest.md`
|
||||||
- Stack Swarm: `stack.yml` (roteado por Traefik, rede `traefik_public`).
|
- Stack Swarm: `stack.yml` (roteado por Traefik, rede `traefik_public`).
|
||||||
|
|
@ -62,11 +62,22 @@ Para fluxos detalhados de desenvolvimento — banco de dados local (SQLite/Prism
|
||||||
## Scripts úteis
|
## Scripts úteis
|
||||||
|
|
||||||
- `pnpm lint` — ESLint com as regras do projeto.
|
- `pnpm lint` — ESLint com as regras do projeto.
|
||||||
- `pnpm exec vitest run` — suíte de testes unitários.
|
- `pnpm test` — suíte de testes unitários (Vitest) em modo não interativo.
|
||||||
|
- `pnpm build` — `next build --turbopack` com otimizações para produção.
|
||||||
- `pnpm auth:seed` — atualiza/cria contas padrão do Better Auth (credenciais em `agents.md`).
|
- `pnpm auth:seed` — atualiza/cria contas padrão do Better Auth (credenciais em `agents.md`).
|
||||||
- `pnpm prisma migrate deploy` — aplica migrações ao banco SQLite local.
|
- `pnpm prisma migrate deploy` — aplica migrações ao banco SQLite local.
|
||||||
- `pnpm convex:dev` — roda o Convex em modo desenvolvimento, gerando tipos em `convex/_generated`.
|
- `pnpm convex:dev` — roda o Convex em modo desenvolvimento, gerando tipos em `convex/_generated`.
|
||||||
|
|
||||||
|
## Transferir máquina entre colaboradores
|
||||||
|
|
||||||
|
Quando uma máquina trocar de responsável:
|
||||||
|
|
||||||
|
1. Abra `Admin > Máquinas`, selecione o equipamento e clique em **Resetar agente**.
|
||||||
|
2. No equipamento, execute o reset local do agente (`rever-agent reset` ou reinstale o serviço) e reprovisione com o código da empresa.
|
||||||
|
3. Após o agente gerar um novo token, associe a máquina ao novo colaborador no painel.
|
||||||
|
|
||||||
|
Sem o reset de agente, o Convex reaproveita o token anterior e o inventário continua vinculado ao usuário antigo.
|
||||||
|
|
||||||
## Estrutura principal
|
## Estrutura principal
|
||||||
|
|
||||||
- `app/` dentro de `src/` — rotas e layouts do Next.js (App Router).
|
- `app/` dentro de `src/` — rotas e layouts do Next.js (App Router).
|
||||||
|
|
|
||||||
|
|
@ -1634,6 +1634,53 @@ export const toggleActive = mutation({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const resetAgent = mutation({
|
||||||
|
args: {
|
||||||
|
machineId: v.id("machines"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { machineId, actorId }) => {
|
||||||
|
const machine = await ctx.db.get(machineId)
|
||||||
|
if (!machine) {
|
||||||
|
throw new ConvexError("Máquina não encontrada")
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = await ctx.db.get(actorId)
|
||||||
|
if (!actor || actor.tenantId !== machine.tenantId) {
|
||||||
|
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||||
|
}
|
||||||
|
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||||
|
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||||
|
if (!STAFF.has(normalizedRole)) {
|
||||||
|
throw new ConvexError("Apenas equipe interna pode resetar o agente da máquina")
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await ctx.db
|
||||||
|
.query("machineTokens")
|
||||||
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
let revokedCount = 0
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!token.revoked) {
|
||||||
|
await ctx.db.patch(token._id, {
|
||||||
|
revoked: true,
|
||||||
|
expiresAt: now,
|
||||||
|
})
|
||||||
|
revokedCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(machineId, {
|
||||||
|
status: "unknown",
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { machineId, revoked: revokedCount }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
type RemoteAccessEntry = {
|
type RemoteAccessEntry = {
|
||||||
id: string
|
id: string
|
||||||
provider: string
|
provider: string
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,29 @@ Este documento consolida as mudanças recentes, o racional por trás delas e o p
|
||||||
- “Acquire Convex admin key” (via container `sistema_convex_backend`).
|
- “Acquire Convex admin key” (via container `sistema_convex_backend`).
|
||||||
- “Bring convex.json from live app if present” (usa o arquivo de link do projeto em `/srv/apps/sistema`).
|
- “Bring convex.json from live app if present” (usa o arquivo de link do projeto em `/srv/apps/sistema`).
|
||||||
- “convex env list” e “convex deploy” com `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY`.
|
- “convex env list” e “convex deploy” com `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY`.
|
||||||
|
|
||||||
|
## 4) Troca de colaborador / reaproveitamento de máquina
|
||||||
|
|
||||||
|
Quando um computador muda de dono (ex.: João entrega o equipamento antigo para Maria e recebe uma máquina nova), siga este checklist para manter o inventário consistente:
|
||||||
|
|
||||||
|
1. **No painel (Admin → Máquinas)**
|
||||||
|
- Abra os detalhes da máquina que será reaproveitada (ex.: a “amarela” que passará da TI/João para a Maria).
|
||||||
|
- Clique em **Resetar agente**. Isso revoga todos os tokens gerados para aquele equipamento; ele precisará ser reprovisionado antes de voltar a reportar dados.
|
||||||
|
- Abra **Ajustar acesso** e altere o e-mail para o do novo usuário (Maria). Assim, quando o agente se registrar novamente, o painel já mostrará a responsável correta.
|
||||||
|
|
||||||
|
2. **Na máquina física que ficará com o novo colaborador**
|
||||||
|
- Desinstale o desktop agent (Painel de Controle → remover programas).
|
||||||
|
- Instale novamente o desktop agent. Use o mesmo **código da empresa/tenant** e informe o **e-mail do novo usuário** (Maria). O backend emite um token novo e reaproveita o registro da máquina, mantendo o histórico.
|
||||||
|
|
||||||
|
3. **Máquina nova para o colaborador antigo**
|
||||||
|
- Instale o desktop agent do zero na máquina que o João vai usar (ex.: a “azul”). Utilize o mesmo código da empresa e o e-mail do João.
|
||||||
|
- A máquina azul aparecerá como um **novo registro** no painel (inventário/tickets começarão do zero). Renomeie/associe conforme necessário.
|
||||||
|
|
||||||
|
4. **Verificação final**
|
||||||
|
- A máquina antiga (amarela) continua listada, agora vinculada à Maria, com seus tickets históricos.
|
||||||
|
- A máquina nova (azul) aparece como um segundo registro para o João. Ajuste hostname/descrição para facilitar a identificação.
|
||||||
|
|
||||||
|
> Não é necessário excluir registros. Cada máquina mantém seu histórico; o reset garante apenas que o token antigo não volte a sobrescrever dados quando o hardware mudar de mãos.
|
||||||
- Importante: não usar `CONVEX_DEPLOYMENT` em conjunto com URL + ADMIN_KEY.
|
- Importante: não usar `CONVEX_DEPLOYMENT` em conjunto com URL + ADMIN_KEY.
|
||||||
|
|
||||||
- Como forçar o deploy do Convex
|
- Como forçar o deploy do Convex
|
||||||
|
|
|
||||||
60
src/app/api/admin/machines/reset-agent/route.ts
Normal file
60
src/app/api/admin/machines/reset-agent/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2280,6 +2280,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
[editingRemoteAccessClientId, remoteAccessEntries]
|
[editingRemoteAccessClientId, remoteAccessEntries]
|
||||||
)
|
)
|
||||||
const [togglingActive, setTogglingActive] = useState(false)
|
const [togglingActive, setTogglingActive] = useState(false)
|
||||||
|
const [isResettingAgent, setIsResettingAgent] = useState(false)
|
||||||
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
|
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
|
||||||
const jsonText = useMemo(() => {
|
const jsonText = useMemo(() => {
|
||||||
const payload = {
|
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) => {
|
const handleCopyRemoteIdentifier = useCallback(async (identifier: string | null | undefined) => {
|
||||||
if (!identifier) return
|
if (!identifier) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -2702,6 +2731,16 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" />
|
||||||
Ajustar acesso
|
Ajustar acesso
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={isActiveLocal ? "outline" : "default"}
|
variant={isActiveLocal ? "outline" : "default"}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, type ReactNode } from "react"
|
import { Suspense, type ReactNode, useEffect, useState } from "react"
|
||||||
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||||
|
|
@ -15,6 +15,14 @@ interface AppShellProps {
|
||||||
|
|
||||||
export function AppShell({ header, children }: AppShellProps) {
|
export function AppShell({ header, children }: AppShellProps) {
|
||||||
const { isLoading } = useAuth()
|
const { isLoading } = useAuth()
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHydrated(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderSkeleton = !hydrated || isLoading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|
@ -22,7 +30,7 @@ export function AppShell({ header, children }: AppShellProps) {
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AuthGuard />
|
<AuthGuard />
|
||||||
</Suspense>
|
</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)">
|
<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">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
<Skeleton className="h-4 w-52" />
|
<Skeleton className="h-4 w-52" />
|
||||||
|
|
@ -37,7 +45,7 @@ export function AppShell({ header, children }: AppShellProps) {
|
||||||
header
|
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">
|
<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="space-y-6">
|
||||||
<div className="px-4 lg:px-6">
|
<div className="px-4 lg:px-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue