docs: registrar fluxo do updater e atualizar chaves

This commit is contained in:
Esdras Renan 2025-10-12 04:06:29 -03:00
parent 206d00700e
commit b5fd920efd
50 changed files with 980 additions and 93 deletions

View file

@ -33,13 +33,15 @@
- `pnpm -C apps/desktop build` — build do frontend (dist).
- `pnpm -C apps/desktop tauri build` — gera instaladores (bundle) por SO.
- Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/`.
- Fluxo:
- Fluxo atualizado:
1) Coleta perfil (hostname/OS/MAC/seriais/métricas).
2) Provisiona via `POST /api/machines/register` com `MACHINE_PROVISIONING_SECRET`.
3) Envia heartbeats a cada 5 min para `/api/machines/heartbeat` com inventário básico.
4) Abre `APP_URL/machines/handshake?token=...` para autenticar sessão na UI.
- Segurança: token salvo no cofre do SO (Keyring). Store guarda apenas metadados não sensíveis.
- Endpoint extra: `POST /api/machines/inventory` (atualiza inventário por token ou provisioningSecret).
2) Provisiona via `POST /api/machines/register` com `MACHINE_PROVISIONING_SECRET`, solicitando o **perfil de acesso** (Colaborador ou Gestor) e os dados do usuário associado. O backend garante a vinculação única da máquina ao colaborador ou gestor informado.
3) Envia heartbeats a cada 5 min para `/api/machines/heartbeat` com inventário básico + estendido (discos, GPUs, serviços, softwares).
4) Abre `APP_URL/machines/handshake?token=...&redirect=...` para autenticar a sessão: colaboradores são direcionados ao portal (`/portal`), gestores ao painel completo (`/dashboard`).
- Segurança: token salvo no cofre do SO (Keyring). Store guarda apenas metadados não sensíveis.
- Endpoint extra: `POST /api/machines/inventory` (atualiza inventário por token ou provisioningSecret).
- Atualizações automáticas: o plugin `@tauri-apps/plugin-updater` verifica `latest.json` nos releases do GitHub. Publicar uma nova release com manifestos atualiza os clientes sem reinstalação manual.
- Ajustes administrativos: em **Admin ▸ Máquinas** é possível vincular ou alterar o perfil (colaborador/gestor) e e-mail associado através do botão “Ajustar acesso”.
## Desenvolvimento local — boas práticas (atualizado)
- Ambientes separados: mantenha seu `.env.local` só para DEV e o `.env` da VPS só para PROD. Nunca commitar arquivos `.env`.
@ -93,6 +95,7 @@ Observações:
- Disparo do deploy web: apenas quando há mudanças em arquivos do app (src/, public/, prisma/, next.config.ts, package.json, pnpm-lock.yaml, tsconfig.json, middleware.ts, stack.yml).
- Disparo do deploy Convex: apenas quando há mudanças em `convex/**`.
- O `.env` da VPS é preservado; caches do servidor (`node_modules`, `.pnpm-store`) não são tocados.
- Smoke de provisionamento (`/api/machines/register` + heartbeat) roda só se `RUN_MACHINE_SMOKE=true` (default: desativado para evitar quedas em caso de instabilidade).
- Banco Prisma (SQLite) persiste em volume nomeado (`sistema_db`); não é recriado a cada deploy.
## Bancos e seeds — DEV x PROD
@ -151,6 +154,7 @@ Observações:
### Papéis
- Papéis válidos: `admin`, `manager`, `agent`, `collaborator` (papel `customer` removido).
- Colaboradores acessam o portal (`/portal`) e visualizam apenas os próprios tickets; gestores herdam a visão completa da empresa mesmo quando autenticados via agente desktop.
- Gestores veem os tickets da própria empresa e só podem registrar comentários públicos.
## Próximos passos sugeridos
@ -167,7 +171,7 @@ Observações:
- Horas por cliente CSV: `/api/reports/hours-by-client.csv?range=7d|30d|90d`
## Referências de inventário de máquinas
- UI (Admin > Máquinas): filtros, pesquisa e export detalhados — ver docs/admin-inventory-ui.md
- UI (Admin > Máquinas): filtros, pesquisa, inventário enriquecido (GPUs, discos, serviços) e exclusão de máquina — ver docs/admin-inventory-ui.md
- Endpoints do agente:
- `POST /api/machines/register`
- `POST /api/machines/heartbeat`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1,020 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Before After
Before After

View file

@ -16,7 +16,8 @@
"title": "Raven",
"width": 1100,
"height": 720,
"resizable": true
"resizable": true,
"fullscreen": true
}
],
"security": {
@ -29,11 +30,13 @@
"https://github.com/esdrasrenan/sistema-de-chamados/releases/latest/download/latest.json"
],
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM1RkE0NkZFMTM0NTA4N0MKUldSOENFVVQva2I2eFZ5TTA0WitpZGRPUXVmbUtjNXNleXlYb1ZKWVlERlZiVzYybUptT1pINlgK"
"active": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM2NTA0QUY2NzRFQ0UzQzYKUldURzQreDA5a3BReGxMTTFQSUpLZmdJRXZSSm1ldzBQTmFpUE5lS0xFeTZTb2Yzb1NJUFZnOTUK"
}
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": [
"deb",
"rpm",

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"
/* eslint-disable @next/next/no-img-element */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { createRoot } from "react-dom/client"
import { invoke } from "@tauri-apps/api/core"
import { Store } from "@tauri-apps/plugin-store"
@ -45,6 +46,12 @@ type MachineRegisterResponse = {
machineToken: string
machineEmail?: string | null
expiresAt?: number | null
persona?: string | null
assignedUserId?: string | null
collaborator?: {
email: string
name?: string | null
} | null
}
type AgentConfig = {
@ -54,6 +61,10 @@ type AgentConfig = {
machineEmail?: string | null
collaboratorEmail?: string | null
collaboratorName?: string | null
accessRole: "collaborator" | "manager"
assignedUserId?: string | null
assignedUserEmail?: string | null
assignedUserName?: string | null
apiBaseUrl: string
appUrl: string
createdAt: number
@ -129,7 +140,14 @@ function App() {
const [company, setCompany] = useState("")
const [collabEmail, setCollabEmail] = useState("")
const [collabName, setCollabName] = useState("")
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">("collaborator")
const [updating, setUpdating] = useState(false)
const [updateInfo, setUpdateInfo] = useState<{ message: string; tone: "info" | "success" | "error" } | null>({
message: "Atualizações automáticas são verificadas a cada inicialização.",
tone: "info",
})
const autoLaunchRef = useRef(false)
const autoUpdateRef = useRef(false)
useEffect(() => {
(async () => {
@ -140,6 +158,7 @@ function App() {
setToken(t)
const cfg = await readConfig(s)
setConfig(cfg)
setAccessRole(cfg?.accessRole ?? "collaborator")
if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail)
if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName)
if (!t) {
@ -147,7 +166,7 @@ function App() {
setProfile(p)
}
setStatus(t ? "online" : null)
} catch (err) {
} catch {
setError("Falha ao carregar estado do agente.")
}
})()
@ -161,7 +180,8 @@ function App() {
const normalizedName = name.length > 0 ? name : null
if (
config.collaboratorEmail === normalizedEmail &&
config.collaboratorName === normalizedName
config.collaboratorName === normalizedName &&
config.accessRole === accessRole
) {
return
}
@ -169,10 +189,11 @@ function App() {
...config,
collaboratorEmail: normalizedEmail,
collaboratorName: normalizedName,
accessRole,
}
setConfig(nextConfig)
writeConfig(store, nextConfig).catch((err) => console.error("Falha ao atualizar colaborador", err))
}, [store, config?.machineId, config?.collaboratorEmail, config?.collaboratorName, collabEmail, collabName])
}, [store, config, config?.collaboratorEmail, config?.collaboratorName, config?.accessRole, collabEmail, collabName, accessRole])
useEffect(() => {
if (!store || !config) return
@ -195,7 +216,7 @@ function App() {
return appUrl
}
return normalized
}, [config?.appUrl, appUrl])
}, [config?.appUrl])
async function register() {
if (!profile) return
@ -205,6 +226,16 @@ function App() {
const collaboratorPayload = collabEmail.trim()
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
: undefined
const collaboratorMetadata = collaboratorPayload
? { ...collaboratorPayload, role: accessRole }
: undefined
const metadataPayload: Record<string, unknown> = {
inventory: profile.inventory,
metrics: profile.metrics,
}
if (collaboratorMetadata) {
metadataPayload.collaborator = collaboratorMetadata
}
const payload = {
provisioningSecret: provisioningSecret.trim(),
tenantId: tenantId.trim() || undefined,
@ -213,7 +244,9 @@ function App() {
os: profile.os,
macAddresses: profile.macAddresses,
serialNumbers: profile.serialNumbers,
metadata: { inventory: profile.inventory, metrics: profile.metrics, collaborator: collaboratorPayload },
metadata: metadataPayload,
accessRole,
collaborator: collaboratorPayload,
registeredBy: "desktop-agent",
}
const res = await fetch(`${apiBaseUrl}/api/machines/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })
@ -231,6 +264,10 @@ function App() {
machineEmail: data.machineEmail ?? null,
collaboratorEmail: collaboratorPayload?.email ?? null,
collaboratorName: collaboratorPayload?.name ?? null,
accessRole,
assignedUserId: data.assignedUserId ?? null,
assignedUserEmail: data.collaborator?.email ?? collaboratorPayload?.email ?? null,
assignedUserName: data.collaborator?.name ?? collaboratorPayload?.name ?? null,
apiBaseUrl,
appUrl,
createdAt: Date.now(),
@ -248,16 +285,19 @@ function App() {
}
}
async function openSystem() {
if (!token || !config) return
const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}`
const openSystem = useCallback(() => {
if (!token) return
const persona = (config?.accessRole ?? accessRole) === "manager" ? "manager" : "collaborator"
const redirectTarget = persona === "manager" ? "/dashboard" : "/portal"
const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
window.location.href = url
}
}, [token, config?.accessRole, accessRole, resolvedAppUrl])
async function reprovision() {
if (!store) return
await store.delete("token"); await store.delete("config"); await store.save()
setToken(null); setConfig(null); setStatus(null)
autoLaunchRef.current = false
setToken(null); setConfig(null); setStatus(null); setAccessRole("collaborator")
const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p)
}
@ -269,9 +309,12 @@ function App() {
const collaboratorPayload = collabEmail.trim()
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
: undefined
const collaboratorInventory = collaboratorPayload
? { ...collaboratorPayload, role: accessRole }
: undefined
const inventoryPayload: Record<string, unknown> = { ...profile.inventory }
if (collaboratorPayload) {
inventoryPayload.collaborator = collaboratorPayload
if (collaboratorInventory) {
inventoryPayload.collaborator = collaboratorInventory
}
const payload = {
machineToken: token,
@ -296,27 +339,61 @@ function App() {
}
}
async function checkForUpdates() {
async function checkForUpdates(auto = false) {
try {
if (!auto) {
setUpdating(true)
setUpdateInfo({ tone: "info", message: "Procurando por atualizações..." })
}
const { check } = await import("@tauri-apps/plugin-updater")
const update = await check()
if (update && (update as any).available) {
// download and install then relaunch
await (update as any).downloadAndInstall()
type UpdateResult = {
available?: boolean
version?: string
downloadAndInstall?: () => Promise<void>
}
const update = (await check()) as UpdateResult | null
if (update?.available) {
setUpdateInfo({
tone: "info",
message: `Atualização ${update.version} disponível. Baixando e aplicando...`,
})
if (typeof update.downloadAndInstall === "function") {
await update.downloadAndInstall()
const { relaunch } = await import("@tauri-apps/plugin-process")
await relaunch()
} else {
alert("Nenhuma atualização disponível.")
}
} else if (!auto) {
setUpdateInfo({ tone: "info", message: "Nenhuma atualização disponível no momento." })
}
} catch (error) {
console.error("Falha ao verificar atualizações", error)
alert("Falha ao verificar atualizações.")
if (!auto) {
setUpdateInfo({
tone: "error",
message: "Falha ao verificar atualizações. Tente novamente mais tarde.",
})
}
} finally {
setUpdating(false)
if (!auto) setUpdating(false)
}
}
useEffect(() => {
if (import.meta.env.DEV) return
if (autoUpdateRef.current) return
autoUpdateRef.current = true
checkForUpdates(true).catch((err: unknown) => {
console.error("Falha ao executar atualização automática", err)
})
}, [])
useEffect(() => {
if (!token) return
if (autoLaunchRef.current) return
autoLaunchRef.current = true
openSystem()
}, [token, config?.accessRole, openSystem])
return (
<div className="min-h-screen grid place-items-center p-6">
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
@ -345,6 +422,20 @@ function App() {
<label className="text-sm font-medium">Empresa (slug opcional)</label>
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="ex.: atlas-engenharia" value={company} onChange={(e)=>setCompany(e.target.value)} />
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Perfil de acesso</label>
<select
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
value={accessRole}
onChange={(e) => setAccessRole((e.target.value as "collaborator" | "manager") ?? "collaborator")}
>
<option value="collaborator">Colaborador (portal)</option>
<option value="manager">Gestor (painel completo)</option>
</select>
<p className="text-xs text-slate-500">
Colaboradores veem apenas seus chamados. Gestores acompanham todos os tickets da empresa.
</p>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Colaborador (e-mail)</label>
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="colaborador@empresa.com" value={collabEmail} onChange={(e)=>setCollabEmail(e.target.value)} />
@ -442,9 +533,25 @@ function App() {
</div>
<div className="grid gap-2">
<label className="label">Atualizações</label>
<button onClick={checkForUpdates} disabled={updating} className={cn("btn btn-outline inline-flex items-center gap-2", updating && "opacity-60")}>
<RefreshCw className="size-4" /> Verificar atualizações
<button
onClick={() => checkForUpdates(false)}
disabled={updating}
className={cn("btn btn-outline inline-flex items-center gap-2", updating && "opacity-60")}
>
<RefreshCw className={cn("size-4", updating && "animate-spin")} /> Verificar atualizações
</button>
{updateInfo ? (
<div
className={cn(
"rounded-lg border px-3 py-2 text-sm leading-snug",
updateInfo.tone === "success" && "border-emerald-200 bg-emerald-50 text-emerald-700",
updateInfo.tone === "error" && "border-rose-200 bg-rose-50 text-rose-700",
updateInfo.tone === "info" && "border-slate-200 bg-slate-50 text-slate-700"
)}
>
{updateInfo.message}
</div>
) : null}
</div>
</TabsContent>
</Tabs>
@ -452,7 +559,7 @@ function App() {
)}
</div>
<div className="mt-6 flex justify-center">
<img src={`${appUrl}/rever-8.png`} alt="Logotipo Rever Tecnologia" width={110} height={110} className="h-[3.45rem] w-auto" />
<img src={`${appUrl}/raven.png`} alt="Logotipo Raven" width={110} height={110} className="h-[3.45rem] w-auto" />
</div>
</div>
)

View file

@ -1,5 +1,3 @@
// @ts-nocheck
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

View file

@ -9,6 +9,7 @@ import type { MutationCtx } from "./_generated/server"
const DEFAULT_TENANT_ID = "tenant-atlas"
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
const ALLOWED_MACHINE_PERSONAS = new Set(["collaborator", "manager"])
type NormalizedIdentifiers = {
macs: string[]
@ -327,6 +328,11 @@ export const register = mutation({
updatedAt: now,
status: "online",
registeredBy: args.registeredBy ?? existing.registeredBy,
persona: existing.persona,
assignedUserId: existing.assignedUserId,
assignedUserEmail: existing.assignedUserEmail,
assignedUserName: existing.assignedUserName,
assignedUserRole: existing.assignedUserRole,
})
machineId = existing._id
} else {
@ -347,6 +353,11 @@ export const register = mutation({
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
assignedUserName: undefined,
assignedUserRole: undefined,
})
}
@ -447,6 +458,11 @@ export const upsertInventory = mutation({
updatedAt: now,
status: args.metrics ? "online" : existing.status ?? "unknown",
registeredBy: args.registeredBy ?? existing.registeredBy,
persona: existing.persona,
assignedUserId: existing.assignedUserId,
assignedUserEmail: existing.assignedUserEmail,
assignedUserName: existing.assignedUserName,
assignedUserRole: existing.assignedUserRole,
})
machineId = existing._id
} else {
@ -467,6 +483,11 @@ export const upsertInventory = mutation({
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
assignedUserName: undefined,
assignedUserRole: undefined,
})
}
@ -570,6 +591,11 @@ export const resolveToken = mutation({
architecture: machine.architecture,
authUserId: machine.authUserId,
authEmail: machine.authEmail,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
status: machine.status,
lastHeartbeatAt: machine.lastHeartbeatAt,
metadata: machine.metadata,
@ -646,6 +672,11 @@ export const listByTenant = query({
serialNumbers: machine.serialNumbers,
authUserId: machine.authUserId ?? null,
authEmail: machine.authEmail ?? null,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
status: derivedStatus,
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
@ -669,6 +700,140 @@ export const listByTenant = query({
},
})
export const updatePersona = mutation({
args: {
machineId: v.id("machines"),
persona: v.optional(v.string()),
assignedUserId: v.optional(v.id("users")),
assignedUserEmail: v.optional(v.string()),
assignedUserName: v.optional(v.string()),
assignedUserRole: v.optional(v.string()),
},
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
}
let nextPersona = machine.persona ?? undefined
const personaProvided = args.persona !== undefined
if (args.persona !== undefined) {
const trimmed = args.persona.trim().toLowerCase()
if (!trimmed) {
nextPersona = undefined
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
throw new ConvexError("Perfil inválido para a máquina")
} else {
nextPersona = trimmed
}
}
let nextAssignedUserId = machine.assignedUserId ?? undefined
if (args.assignedUserId !== undefined) {
nextAssignedUserId = args.assignedUserId
}
let nextAssignedEmail = machine.assignedUserEmail ?? undefined
if (args.assignedUserEmail !== undefined) {
const trimmedEmail = args.assignedUserEmail.trim().toLowerCase()
nextAssignedEmail = trimmedEmail || undefined
}
let nextAssignedName = machine.assignedUserName ?? undefined
if (args.assignedUserName !== undefined) {
const trimmedName = args.assignedUserName.trim()
nextAssignedName = trimmedName || undefined
}
let nextAssignedRole = machine.assignedUserRole ?? undefined
if (args.assignedUserRole !== undefined) {
const trimmedRole = args.assignedUserRole.trim().toUpperCase()
nextAssignedRole = trimmedRole || undefined
}
if (nextPersona && !nextAssignedUserId) {
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
}
if (nextAssignedUserId) {
const assignedUser = await ctx.db.get(nextAssignedUserId)
if (!assignedUser) {
throw new ConvexError("Usuário vinculado não encontrado")
}
if (assignedUser.tenantId !== machine.tenantId) {
throw new ConvexError("Usuário vinculado pertence a outro tenant")
}
}
let nextMetadata = machine.metadata
if (nextPersona) {
const collaboratorMeta = {
email: nextAssignedEmail ?? null,
name: nextAssignedName ?? null,
role: nextPersona,
}
nextMetadata = mergeMetadata(machine.metadata, { collaborator: collaboratorMeta })
}
const patch: Record<string, unknown> = {
persona: nextPersona,
assignedUserId: nextPersona ? nextAssignedUserId : undefined,
assignedUserEmail: nextPersona ? nextAssignedEmail : undefined,
assignedUserName: nextPersona ? nextAssignedName : undefined,
assignedUserRole: nextPersona ? nextAssignedRole : undefined,
updatedAt: Date.now(),
}
if (nextMetadata !== machine.metadata) {
patch.metadata = nextMetadata
}
if (personaProvided) {
patch.persona = nextPersona
}
if (nextPersona) {
patch.assignedUserId = nextAssignedUserId
patch.assignedUserEmail = nextAssignedEmail
patch.assignedUserName = nextAssignedName
patch.assignedUserRole = nextAssignedRole
} else if (personaProvided) {
patch.assignedUserId = undefined
patch.assignedUserEmail = undefined
patch.assignedUserName = undefined
patch.assignedUserRole = undefined
}
await ctx.db.patch(machine._id, patch)
return { ok: true, persona: nextPersona ?? null }
},
})
export const getContext = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
}
return {
id: machine._id,
tenantId: machine.tenantId,
companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? null,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
metadata: machine.metadata ?? null,
authEmail: machine.authEmail ?? null,
}
},
})
export const linkAuthAccount = mutation({
args: {
machineId: v.id("machines"),
@ -710,7 +875,7 @@ export const rename = mutation({
throw new ConvexError("Acesso negado ao tenant da máquina")
}
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
}
@ -741,7 +906,7 @@ export const remove = mutation({
throw new ConvexError("Acesso negado ao tenant da máquina")
}
const role = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(role)) {
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
}

View file

@ -7,7 +7,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"])
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"])
function normalizeEmail(value: string) {
return value.trim().toLowerCase()

View file

@ -3,7 +3,7 @@ import { ConvexError } from "convex/values"
import type { Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"])
const MANAGER_ROLE = "MANAGER"
type Ctx = QueryCtx | MutationCtx

View file

@ -245,6 +245,11 @@ export default defineSchema({
companySlug: v.optional(v.string()),
authUserId: v.optional(v.string()),
authEmail: v.optional(v.string()),
persona: v.optional(v.string()),
assignedUserId: v.optional(v.id("users")),
assignedUserEmail: v.optional(v.string()),
assignedUserName: v.optional(v.string()),
assignedUserRole: v.optional(v.string()),
hostname: v.string(),
osName: v.string(),
osVersion: v.optional(v.string()),
@ -261,7 +266,8 @@ export default defineSchema({
})
.index("by_tenant", ["tenantId"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_fingerprint", ["tenantId", "fingerprint"]),
.index("by_tenant_fingerprint", ["tenantId", "fingerprint"])
.index("by_auth_email", ["authEmail"]),
machineTokens: defineTable({
tenantId: v.string(),

View file

@ -5,8 +5,8 @@ import { Id, type Doc } from "./_generated/dataModel";
import { requireStaff, requireUser } from "./rbac";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
const PAUSE_REASON_LABELS: Record<string, string> = {
NO_CONTACT: "Falta de contato",
WAITING_THIRD_PARTY: "Aguardando terceiro",

View file

@ -12,6 +12,7 @@ export const ensureUser = mutation({
avatarUrl: v.optional(v.string()),
role: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
companyId: v.optional(v.id("companies")),
},
handler: async (ctx, args) => {
const existing = await ctx.db
@ -25,7 +26,8 @@ export const ensureUser = mutation({
(args.role && record.role !== args.role) ||
(args.avatarUrl && record.avatarUrl !== args.avatarUrl) ||
record.name !== args.name ||
(args.teams && JSON.stringify(args.teams) !== JSON.stringify(record.teams ?? []));
(args.teams && JSON.stringify(args.teams) !== JSON.stringify(record.teams ?? [])) ||
(args.companyId && record.companyId !== args.companyId);
if (shouldPatch) {
await ctx.db.patch(record._id, {
@ -34,6 +36,7 @@ export const ensureUser = mutation({
avatarUrl: args.avatarUrl ?? record.avatarUrl,
name: args.name,
teams: args.teams ?? record.teams,
companyId: args.companyId ?? record.companyId,
});
const updated = await ctx.db.get(record._id);
if (updated) {
@ -64,6 +67,7 @@ export const ensureUser = mutation({
avatarUrl: args.avatarUrl,
role: args.role ?? "AGENT",
teams: args.teams ?? [],
companyId: args.companyId,
});
return await ctx.db.get(id);
},

View file

@ -48,11 +48,19 @@ Observação: evitar `prisma/.env` nesse setup, pois causa conflito com o `.env`
- Onde foram feitas as mudanças principais:
- `apps/desktop/src/components/ui/tabs.tsx` (Tabs Radix + estilos shadcn-like)
- `apps/desktop/src/main.tsx` (layout com abas: Resumo/Inventário/Diagnóstico/Configurações; status badge; botão “Enviar inventário agora”).
- `apps/desktop/src/main.tsx` (layout com abas: Resumo/Inventário/Diagnóstico/Configurações; status badge; botão “Enviar inventário agora”; seleção do perfil de acesso colaborador/gestor e sincronização do usuário vinculado).
- `apps/desktop/src-tauri/src/agent.rs` (coleta e normalização de hardware, discos, GPUs e inventário estendido por SO).
- Variáveis de ambiente do Desktop (em tempo de build):
- `VITE_APP_URL` e `VITE_API_BASE_URL` — por padrão, use a URL da aplicação web.
### Atualizações automáticas (GitHub)
1. Gere o par de chaves do updater (`pnpm tauri signer generate -- -w ~/.tauri/raven.key`) e configure as variáveis de ambiente `TAURI_SIGNING_PRIVATE_KEY` e `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` antes de rodar `pnpm -C apps/desktop tauri build`.
2. Garanta que `bundle.createUpdaterArtifacts` esteja habilitado (já configurado) para gerar os pacotes `.nsis`/`.AppImage` e os arquivos `.sig`.
3. Publique os artefatos de cada SO em um release do GitHub e atualize o `latest.json` público (ex.: no próprio repositório ou em um gist) com `version`, `notes`, `pub_date` e as entradas por plataforma (`url` e `signature`).
4. O agente já consulta o updater ao iniciar e também possui o botão “Verificar atualizações” na aba Configurações. Ao detectar nova versão o download é feito em segundo plano e o app reinicia automaticamente após o `downloadAndInstall`.
### Build do executável localmente
Você pode gerar o executável local sem precisar da VPS. O que muda é apenas o sistema operacional alvo (Linux/Windows/macOS). O Tauri recomenda compilar em cada SO para obter o bundle nativo desse SO. Em produção, o GitHub Actions já faz isso em matriz.
@ -77,6 +85,7 @@ pnpm -C apps/desktop tauri build
- Os artefatos ficam em: `apps/desktop/src-tauri/target/release/bundle/`
- No Linux: `.AppImage`/`.deb`/`.rpm` (conforme target)
- No Windows/macOS: executável/instalador específicos do SO (para assinatura, usar chaves/AC, se desejado)
- Para liberar atualizações OTA, publique release no GitHub com artefatos e `latest.json` — o plugin de updater verifica a URL configurada em `tauri.conf.json`.
### Build na VPS x Local
@ -86,6 +95,7 @@ pnpm -C apps/desktop tauri build
- `desktop-release.yml` (Tauri): instala dependências, faz build por SO e publica artefatos. Mantendo o `pnpm-lock.yaml` atualizado, o passo `--frozen-lockfile` passa.
- `ci-cd-web-desktop.yml`: já usa `pnpm install --no-frozen-lockfile` no web, evitando falhas em pipelines de integração.
- Smoke de provisionamento pode ser desligado definindo `RUN_MACHINE_SMOKE=false` (default); quando quiser exercitar o fluxo complete register/heartbeat, defina `RUN_MACHINE_SMOKE=true`.
## Troubleshooting
@ -95,4 +105,3 @@ pnpm -C apps/desktop tauri build
- `ERR_PNPM_OUTDATED_LOCKFILE` no Desktop:
- Atualize `pnpm-lock.yaml` no root após alterar dependências de `apps/desktop/package.json`.
- Alternativa: usar `--no-frozen-lockfile` (não recomendado para releases reproduzíveis).

View file

@ -10,15 +10,17 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
- Marcação “Somente com alertas” para investigar postura.
## Painel de detalhes
- Resumo: hostname, status, e-mail vinculado, SO/arch, sincronização do token (expiração/uso).
- Resumo: hostname, status, e-mail vinculado, empresa (quando houver), perfil de acesso (colaborador/gestor) com dados do usuário associado, SO/arch e sincronização do token (expiração/uso).
- Métricas recentes: CPU/Memory/Disco.
- Inventário básico: hardware (CPU/mem/serial), rede (IP/MAC), labels.
- Inventário básico: hardware (CPU/mem/serial, GPUs detectadas), rede (IP/MAC), labels.
- Discos e partições: nome, mount, FS, capacidade, livre.
- Inventário estendido (varia por SO):
- Linux: SMART (OK/ALERTA), `lspci`, `lsusb` (texto), `lsblk` (interno para discos).
- Windows: serviços (amostra), softwares instalados (amostra), Defender.
- Windows: serviços (amostra), softwares instalados (amostra), Defender, resumo de hardware (CPU/Memória/GPU/Discos físicos).
- macOS: pacotes (`pkgutil`), serviços (`launchctl`).
- Postura/Alertas: CPU alta, serviço parado, SMART em falha com severidade e última avaliação.
- Zona perigosa: ação para excluir a máquina (revoga tokens e remove inventário).
- Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a máquina.
## Exportação
- Copiar JSON: copia para a área de transferência todo o inventário exibido (métricas + inventário + alertas).

111
docs/desktop-updater.md Normal file
View file

@ -0,0 +1,111 @@
# Checklist de Publicação — Updater do Agente Desktop
Este guia consolida tudo o que precisa ser feito para que o auto-update do Tauri funcione em cada release.
---
## 1. Preparação (uma única vez)
1. **Gerar o par de chaves**
Execute na raiz do repositório (aproveite que o comando já foi rodado):
```bash
pnpm -C apps/desktop tauri signer generate -w ~/.tauri/raven.key
```
- Privada: `~/.tauri/raven.key` (nunca compartilhar)
- Pública: `~/.tauri/raven.key.pub` (cole em `tauri.conf.json > plugins.updater.pubkey`)
2. **Verificar o `tauri.conf.json`**
```json
{
"bundle": { "createUpdaterArtifacts": true },
"plugins": {
"updater": {
"active": true,
"endpoints": ["https://.../latest.json"],
"pubkey": "<conteúdo da raven.key.pub>"
}
}
}
```
---
## 2. Antes de cada release
1. **Sincronizar versão** (mesmo número nos três arquivos):
- `apps/desktop/package.json`
- `apps/desktop/src-tauri/tauri.conf.json`
- `apps/desktop/src-tauri/Cargo.toml`
2. **Build do front (gera `dist/` para o Tauri)**
```bash
pnpm -C apps/desktop build
```
3. **Exportar variáveis do assinador** (no mesmo shell em que vai buildar):
```bash
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/raven.key)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="<senha-da-chave>"
```
> No PowerShell, use `setx` para persistir ou execute `set`/`$env:` no terminal atual.
4. **Gerar os instaladores + `.sig`**
```bash
pnpm -C apps/desktop tauri build
```
Os artefatos ficam em `apps/desktop/src-tauri/target/release/bundle/`:
| SO | Bundle principal | Assinatura gerada |
|----------|----------------------------------------------|-----------------------------------------|
| Windows | `nsis/Raven_0.X.Y_x64-setup.exe` | `nsis/Raven_0.X.Y_x64-setup.exe.sig` |
| Linux | `appimage/Raven_0.X.Y_amd64.AppImage` | `appimage/Raven_0.X.Y_amd64.AppImage.sig` |
| macOS | `macos/Raven.app.tar.gz` | `macos/Raven.app.tar.gz.sig` |
---
## 3. Publicar no GitHub
1. **Criar/atualizar release** (ex.: `v0.1.6`) anexando todos os instaladores e seus `.sig`.
2. **Atualizar `latest.json`** (no próprio repo ou em um gist público) com algo como:
```json
{
"version": "0.1.6",
"notes": "Novidades do release",
"pub_date": "2025-10-12T08:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "<conteúdo de Raven_0.1.6_x64-setup.exe.sig>",
"url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven_0.1.6_x64-setup.exe"
},
"linux-x86_64": {
"signature": "<conteúdo de Raven_0.1.6_amd64.AppImage.sig>",
"url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven_0.1.6_amd64.AppImage"
},
"darwin-x86_64": {
"signature": "<conteúdo de Raven.app.tar.gz.sig>",
"url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven.app.tar.gz"
}
}
}
```
- Pegue o link **Raw** do `latest.json` e mantenha igual ao usado no `tauri.conf.json`.
---
## 4. Validar rapidamente
1. Instale a versão anterior (ex.: 0.1.5) e abra.
2. O agente deve avisar sobre a nova versão e reiniciar automaticamente ao concluir a instalação.
3. Caso queira forçar manualmente, abra a aba **Configurações → Verificar atualizações**.
---
## 5. Resumo rápido
1. `pnpm -C apps/desktop build`
2. `export TAURI_SIGNING_PRIVATE_KEY=...` / `export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=...`
3. `pnpm -C apps/desktop tauri build`
4. Upload dos bundles + `.sig` → atualizar `latest.json`
5. Testar o instalador antigo para garantir que atualiza sozinho
Com isso, os usuários sempre receberão a versão mais recente assim que abrirem o agente desktop.

View file

@ -17,6 +17,8 @@ const eslintConfig = [
".next/**",
"out/**",
"build/**",
"apps/desktop/dist/**",
"apps/desktop/src-tauri/target/**",
"next-env.d.ts",
"convex/_generated/**",
],

View file

@ -3,7 +3,6 @@ import { getCookieCache } from "better-auth/cookies"
const PUBLIC_PATHS = [/^\/login$/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/]
const ADMIN_ONLY_PATHS = [/^\/admin(?:$|\/)/]
const PORTAL_HOME = "/portal"
const APP_HOME = "/dashboard"
export async function middleware(request: NextRequest) {

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AuthUser" ADD COLUMN "machinePersona" TEXT;

View file

@ -217,6 +217,7 @@ model AuthUser {
role String @default("agent")
tenantId String?
avatarUrl String?
machinePersona String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions AuthSession[]

BIN
public/raven.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -0,0 +1,78 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { assertAdminSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
const schema = z.object({
machineId: z.string().min(1),
persona: z.enum(["collaborator", "manager"]),
email: z.string().email(),
name: z.string().optional(),
})
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
let parsed: z.infer<typeof schema>
try {
const body = await request.json()
parsed = schema.parse(body)
} catch {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
}
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
}
const client = new ConvexHttpClient(convexUrl)
try {
const machine = (await client.query(api.machines.getContext, {
machineId: parsed.machineId as Id<"machines">,
})) as {
id: string
tenantId: string
companyId: string | null
} | null
if (!machine) {
return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 })
}
const tenantId = machine.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID
const ensuredUser = (await client.mutation(api.users.ensureUser, {
tenantId,
email: parsed.email,
name: parsed.name ?? parsed.email,
avatarUrl: undefined,
role: parsed.persona.toUpperCase(),
companyId: machine.companyId ? (machine.companyId as Id<"companies">) : undefined,
})) as { _id?: Id<"users"> } | null
await client.mutation(api.machines.updatePersona, {
machineId: parsed.machineId as Id<"machines">,
persona: parsed.persona,
assignedUserId: ensuredUser?._id,
assignedUserEmail: parsed.email,
assignedUserName: parsed.name ?? undefined,
assignedUserRole: parsed.persona === "manager" ? "MANAGER" : "COLLABORATOR",
})
return NextResponse.json({ ok: true })
} catch (error) {
console.error("[machines.access]", error)
return NextResponse.json({ error: "Falha ao atualizar acesso da máquina" }, { status: 500 })
}
}

View file

@ -23,6 +23,13 @@ const registerSchema = z
serialNumbers: z.array(z.string()).default([]),
metadata: z.record(z.string(), z.unknown()).optional(),
registeredBy: z.string().optional(),
accessRole: z.enum(["collaborator", "manager"]).optional(),
collaborator: z
.object({
email: z.string().email(),
name: z.string().optional(),
})
.optional(),
})
.refine(
(data) => (data.macAddresses && data.macAddresses.length > 0) || (data.serialNumbers && data.serialNumbers.length > 0),
@ -61,15 +68,43 @@ export async function POST(request: Request) {
const client = new ConvexHttpClient(convexUrl)
try {
const tenantId = payload.tenantId ?? DEFAULT_TENANT_ID
const persona = payload.accessRole ?? undefined
const collaborator = payload.collaborator ?? null
if (persona && !collaborator) {
return jsonWithCors(
{ error: "Informe os dados do colaborador/gestor ao definir o perfil de acesso." },
400,
request.headers.get("origin"),
CORS_METHODS
)
}
let metadataPayload: Record<string, unknown> | undefined = payload.metadata
? { ...(payload.metadata as Record<string, unknown>) }
: undefined
if (collaborator) {
const collaboratorMeta = {
email: collaborator.email,
name: collaborator.name ?? null,
role: persona ?? "collaborator",
}
metadataPayload = {
...(metadataPayload ?? {}),
collaborator: collaboratorMeta,
}
}
const registration = await client.mutation(api.machines.register, {
provisioningSecret: payload.provisioningSecret,
tenantId: payload.tenantId ?? DEFAULT_TENANT_ID,
tenantId,
companySlug: payload.companySlug ?? undefined,
hostname: payload.hostname,
os: payload.os,
macAddresses: payload.macAddresses,
serialNumbers: payload.serialNumbers,
metadata: payload.metadata,
metadata: metadataPayload,
registeredBy: payload.registeredBy,
})
@ -78,6 +113,7 @@ export async function POST(request: Request) {
tenantId: registration.tenantId ?? DEFAULT_TENANT_ID,
hostname: payload.hostname,
machineToken: registration.machineToken,
persona,
})
await client.mutation(api.machines.linkAuthAccount, {
@ -86,6 +122,34 @@ export async function POST(request: Request) {
authEmail: account.authEmail,
})
let assignedUserId: Id<"users"> | undefined
if (persona && collaborator) {
const ensuredUser = (await client.mutation(api.users.ensureUser, {
tenantId,
email: collaborator.email,
name: collaborator.name ?? collaborator.email,
avatarUrl: undefined,
role: persona.toUpperCase(),
companyId: registration.companyId ? (registration.companyId as Id<"companies">) : undefined,
})) as { _id?: Id<"users"> } | null
assignedUserId = ensuredUser?._id
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona,
...(assignedUserId ? { assignedUserId } : {}),
assignedUserEmail: collaborator.email,
assignedUserName: collaborator.name ?? undefined,
assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR",
})
} else {
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona: "",
})
}
return jsonWithCors(
{
machineId: registration.machineId,
@ -95,6 +159,9 @@ export async function POST(request: Request) {
machineToken: registration.machineToken,
machineEmail: account.authEmail,
expiresAt: registration.expiresAt,
persona: persona ?? null,
assignedUserId: assignedUserId ?? null,
collaborator: collaborator ?? null,
},
{ status: 201 },
request.headers.get("origin"),

View file

@ -0,0 +1,77 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
const MACHINE_CTX_COOKIE = "machine_ctx"
function decodeMachineCookie(value: string) {
try {
const json = Buffer.from(value, "base64url").toString("utf8")
return JSON.parse(json) as {
machineId: string
persona: string | null
assignedUserId: string | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
}
} catch {
return null
}
}
export async function GET() {
const session = await assertAuthenticatedSession()
if (!session || session.user?.role !== "machine") {
return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 })
}
const cookieStore = await cookies()
const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value
if (!cookieValue) {
return NextResponse.json({ error: "Contexto da máquina ausente." }, { status: 404 })
}
const decoded = decodeMachineCookie(cookieValue)
if (!decoded?.machineId) {
return NextResponse.json({ error: "Contexto da máquina inválido." }, { status: 400 })
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado." }, { status: 500 })
}
const client = new ConvexHttpClient(convexUrl)
try {
const context = (await client.query(api.machines.getContext, {
machineId: decoded.machineId as Id<"machines">,
})) as {
id: string
tenantId: string
companyId: string | null
companySlug: string | null
persona: string | null
assignedUserId: string | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
metadata: Record<string, unknown> | null
authEmail: string | null
}
return NextResponse.json({
machine: context,
cookie: decoded,
})
} catch (error) {
console.error("[machines.session] Falha ao obter contexto da máquina", error)
return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 })
}
}

View file

@ -47,6 +47,24 @@ export async function POST(request: Request) {
response.headers.set(key, value)
})
const machineCookiePayload = {
machineId: session.machine.id,
persona: session.machine.persona,
assignedUserId: session.machine.assignedUserId,
assignedUserEmail: session.machine.assignedUserEmail,
assignedUserName: session.machine.assignedUserName,
assignedUserRole: session.machine.assignedUserRole,
}
response.cookies.set({
name: "machine_ctx",
value: Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url"),
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
maxAge: 60 * 60 * 24 * 30,
})
applyCorsHeaders(response, request.headers.get("origin"), CORS_METHODS)
return response

View file

@ -269,7 +269,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
// Header with logo and brand bar
try {
const logoPath = path.join(process.cwd(), "public", "rever-8.png")
const logoPath = path.join(process.cwd(), "public", "raven.png")
if (fs.existsSync(logoPath)) {
doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 })
}

View file

@ -21,7 +21,7 @@ export const metadata: Metadata = {
title: "Raven",
description: "Plataforma Raven da Rever",
icons: {
icon: "/rever-8.png",
icon: "/raven.png",
},
}

View file

@ -53,7 +53,7 @@ export function LoginPageClient() {
</div>
<div className="flex justify-center">
<Image
src="/rever-8.png"
src="/raven.png"
alt="Logotipo Rever Tecnologia"
width={110}
height={110}

View file

@ -58,6 +58,25 @@ export async function GET(request: NextRequest) {
}
})
const machineCookiePayload = {
machineId: session.machine.id,
persona: session.machine.persona,
assignedUserId: session.machine.assignedUserId,
assignedUserEmail: session.machine.assignedUserEmail,
assignedUserName: session.machine.assignedUserName,
assignedUserRole: session.machine.assignedUserRole,
}
const encodedContext = Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url")
response.cookies.set({
name: "machine_ctx",
value: encodedContext,
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
maxAge: 60 * 60 * 24 * 30,
})
return response
} catch (error) {
console.error("[machines.handshake] Falha ao autenticar máquina", error)

View file

@ -119,7 +119,7 @@ type MachineInventory = {
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; interface?: string | null; serial?: string | null; totalBytes?: number; availableBytes?: number }>
extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
services?: Array<{ name?: string; status?: string; displayName?: string }>
collaborator?: { email?: string; name?: string }
collaborator?: { email?: string; name?: string; role?: string }
}
export type MachinesQueryItem = {
@ -135,6 +135,11 @@ export type MachinesQueryItem = {
serialNumbers: string[]
authUserId: string | null
authEmail: string | null
persona: string | null
assignedUserId: string | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
status: string | null
lastHeartbeatAt: number | null
heartbeatAgeMs: number | null
@ -209,12 +214,6 @@ function formatPercent(value?: number | null) {
return `${normalized.toFixed(0)}%`
}
function fmtBool(value: unknown) {
if (value === true) return "Sim"
if (value === false) return "Não"
return "—"
}
function readBool(source: unknown, key: string): boolean | undefined {
if (!source || typeof source !== "object") return undefined
const value = (source as Record<string, unknown>)[key]
@ -490,15 +489,31 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}
}
// collaborator (from inventory metadata, when provided by onboarding)
type Collaborator = { email?: string; name?: string }
const collaborator: Collaborator | null = (() => {
// collaborator (from machine assignment or metadata)
type Collaborator = { email?: string; name?: string; role?: string }
const collaborator: Collaborator | null = useMemo(() => {
if (machine?.assignedUserEmail) {
return {
email: machine.assignedUserEmail ?? undefined,
name: machine.assignedUserName ?? undefined,
role: machine.persona ?? machine.assignedUserRole ?? undefined,
}
}
if (!metadata || typeof metadata !== "object") return null
const inv = metadata as Record<string, unknown>
const c = inv["collaborator"]
if (c && typeof c === "object") return c as Collaborator
if (c && typeof c === "object") {
const base = c as Record<string, unknown>
return {
email: typeof base.email === "string" ? base.email : undefined,
name: typeof base.name === "string" ? base.name : undefined,
role: typeof base.role === "string" ? (base.role as string) : undefined,
}
}
return null
})()
}, [machine?.assignedUserEmail, machine?.assignedUserName, machine?.persona, machine?.assignedUserRole, metadata])
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
const companyName = (() => {
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
@ -513,6 +528,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const [dialogQuery, setDialogQuery] = useState("")
const [deleteDialog, setDeleteDialog] = useState(false)
const [deleting, setDeleting] = useState(false)
const [accessDialog, setAccessDialog] = useState(false)
const [accessEmail, setAccessEmail] = useState<string>(collaborator?.email ?? "")
const [accessName, setAccessName] = useState<string>(collaborator?.name ?? "")
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
)
const [savingAccess, setSavingAccess] = useState(false)
const jsonText = useMemo(() => {
const payload = {
id: machine?.id,
@ -535,6 +557,42 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}, [jsonText, dialogQuery])
// removed copy/export inventory JSON buttons as requested
useEffect(() => {
setAccessEmail(collaborator?.email ?? "")
setAccessName(collaborator?.name ?? "")
setAccessRole((machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator")
}, [machine?.id, machine?.persona, collaborator?.email, collaborator?.name, collaborator?.role])
const handleSaveAccess = async () => {
if (!machine) return
if (!accessEmail.trim()) {
toast.error("Informe o e-mail do colaborador ou gestor.")
return
}
setSavingAccess(true)
try {
const response = await fetch("/api/admin/machines/access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineId: machine.id,
persona: accessRole,
email: accessEmail.trim(),
name: accessName.trim() || undefined,
}),
})
if (!response.ok) {
throw new Error(await response.text())
}
toast.success("Perfil de acesso atualizado.")
setAccessDialog(false)
} catch (error) {
console.error(error)
toast.error("Falha ao atualizar acesso da máquina.")
} finally {
setSavingAccess(false)
}
}
return (
<Card className="border-slate-200">
@ -594,7 +652,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
) : null}
{collaborator?.email ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Colaborador: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
{personaLabel}: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
</Badge>
) : null}
</div>
@ -605,6 +663,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
Copiar e-mail
</Button>
) : null}
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
<ShieldCheck className="size-4" />
Ajustar acesso
</Button>
{machine.registeredBy ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Registrada via {machine.registeredBy}
@ -653,6 +715,42 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</DialogContent>
</Dialog>
<Dialog open={accessDialog} onOpenChange={setAccessDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ajustar acesso da máquina</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-2">
<label className="text-sm font-medium">Perfil</label>
<Select value={accessRole} onValueChange={(value) => setAccessRole((value as "collaborator" | "manager") ?? "collaborator")}>
<SelectTrigger>
<SelectValue placeholder="Selecione o perfil" />
</SelectTrigger>
<SelectContent>
<SelectItem value="collaborator">Colaborador (portal)</SelectItem>
<SelectItem value="manager">Gestor (painel completo)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">E-mail</label>
<Input type="email" value={accessEmail} onChange={(e) => setAccessEmail(e.target.value)} placeholder="colaborador@empresa.com" />
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Nome (opcional)</label>
<Input value={accessName} onChange={(e) => setAccessName(e.target.value)} placeholder="Nome completo" />
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAccessDialog(false)} disabled={savingAccess}>Cancelar</Button>
<Button onClick={handleSaveAccess} disabled={savingAccess || !accessEmail.trim()}>
{savingAccess ? "Salvando..." : "Salvar"}
</Button>
</div>
</DialogContent>
</Dialog>
<section className="space-y-2">
<h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground">
@ -1372,16 +1470,27 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
const memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN)
const cpuPct = mm?.cpuUsagePercent ?? NaN
const collaborator = (() => {
if (machine.assignedUserEmail) {
return {
email: machine.assignedUserEmail ?? undefined,
name: machine.assignedUserName ?? undefined,
role: machine.persona ?? machine.assignedUserRole ?? undefined,
}
}
const inv = machine.inventory as unknown
if (!inv || typeof inv !== "object") return null
const raw = (inv as Record<string, unknown>).collaborator
if (!raw || typeof raw !== "object") return null
const obj = raw as Record<string, unknown>
const email = typeof obj.email === "string" ? obj.email : undefined
const name = typeof obj.name === "string" ? obj.name : undefined
if (!email) return null
return { email, name }
return {
email,
name: typeof obj.name === "string" ? obj.name : undefined,
role: typeof obj.role === "string" ? (obj.role as string) : undefined,
}
})()
const persona = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
const companyLabel = companyName ?? machine.companySlug ?? null
return (
@ -1427,7 +1536,7 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
</div>
{collaborator?.email ? (
<p className="text-[11px] text-muted-foreground">
{collaborator.name ? `${collaborator.name} · ` : ""}
{persona}: {collaborator.name ? `${collaborator.name} · ` : ""}
{collaborator.email}
</p>
) : null}

View file

@ -23,17 +23,21 @@ const navItems = [
export function PortalShell({ children }: PortalShellProps) {
const pathname = usePathname()
const router = useRouter()
const { session } = useAuth()
const { session, machineContext } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false)
const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente"
const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? ""
const personaLabel = machineContext?.persona === "manager" ? "Gestor" : "Colaborador"
const initials = useMemo(() => {
const name = session?.user.name || session?.user.email || "Cliente"
const name = displayName || displayEmail || "Cliente"
return name
.split(" ")
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("")
}, [session?.user.name, session?.user.email])
}, [displayName, displayEmail])
async function handleSignOut() {
if (isSigningOut) return
@ -85,12 +89,15 @@ export function PortalShell({ children }: PortalShellProps) {
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm">
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={session?.user.name ?? ""} />
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={displayName ?? ""} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col leading-tight">
<span className="font-semibold text-neutral-900">{session?.user.name ?? "Cliente"}</span>
<span className="text-xs text-neutral-500">{session?.user.email ?? ""}</span>
<span className="font-semibold text-neutral-900">{displayName}</span>
<span className="text-xs text-neutral-500">{displayEmail}</span>
{machineContext ? (
<span className="text-[10px] uppercase tracking-wide text-neutral-400">{personaLabel}</span>
) : null}
</div>
</div>
<Button

View file

@ -18,6 +18,7 @@ export type AppSession = {
role: string
tenantId: string | null
avatarUrl: string | null
machinePersona?: string | null
}
session: {
id: string
@ -25,6 +26,17 @@ export type AppSession = {
}
}
type MachineContext = {
machineId: string
tenantId: string
persona: string | null
assignedUserId: string | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
companyId: string | null
}
const authClient = createAuthClient({
plugins: [customSessionClient<AppAuth>()],
fetchOptions: {
@ -40,6 +52,7 @@ type AuthContextValue = {
isAdmin: boolean
isStaff: boolean
isCustomer: boolean
machineContext: MachineContext | null
}
const AuthContext = createContext<AuthContextValue>({
@ -50,6 +63,7 @@ const AuthContext = createContext<AuthContextValue>({
isAdmin: false,
isStaff: false,
isCustomer: false,
machineContext: null,
})
export function useAuth() {
@ -62,15 +76,68 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, isPending } = useSession()
const ensureUser = useMutation(api.users.ensureUser)
const [convexUserId, setConvexUserId] = useState<string | null>(null)
const [machineContext, setMachineContext] = useState<MachineContext | null>(null)
useEffect(() => {
if (!session?.user) {
if (!session?.user || session.user.role === "machine") {
setConvexUserId(null)
}
}, [session?.user])
useEffect(() => {
if (!session?.user || convexUserId) return
if (!session?.user || session.user.role !== "machine") {
setMachineContext(null)
return
}
let cancelled = false
;(async () => {
try {
const response = await fetch("/api/machines/session", { credentials: "include" })
if (!response.ok) {
if (!cancelled) {
setMachineContext(null)
}
return
}
const data = await response.json()
if (!cancelled) {
const machine = data.machine as {
id: string
tenantId: string
persona: string | null
assignedUserId: string | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
companyId: string | null
}
setMachineContext({
machineId: machine.id,
tenantId: machine.tenantId,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
companyId: machine.companyId ?? null,
})
}
} catch (error) {
console.error("Failed to load machine context", error)
if (!cancelled) {
setMachineContext(null)
}
}
})()
return () => {
cancelled = true
}
}, [session?.user])
useEffect(() => {
if (!session?.user || session.user.role === "machine" || convexUserId) return
const controller = new AbortController()
@ -99,19 +166,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ensureUser, session?.user?.email, session?.user?.tenantId, session?.user?.role, convexUserId])
const normalizedRole = session?.user?.role ? session.user.role.toLowerCase() : null
const baseRole = session?.user?.role ? session.user.role.toLowerCase() : null
const personaRole = session?.user?.machinePersona ? session.user.machinePersona.toLowerCase() : null
const normalizedRole =
baseRole === "machine" ? machineContext?.persona ?? personaRole ?? null : baseRole
const effectiveConvexUserId =
baseRole === "machine" ? machineContext?.assignedUserId ?? null : convexUserId
const value = useMemo<AuthContextValue>(
() => ({
session: session ?? null,
isLoading: isPending,
convexUserId,
convexUserId: effectiveConvexUserId,
role: normalizedRole,
isAdmin: isAdmin(normalizedRole),
isStaff: isStaff(normalizedRole),
isCustomer: false,
isCustomer: normalizedRole === "collaborator",
machineContext,
}),
[session, isPending, convexUserId, normalizedRole]
[session, isPending, effectiveConvexUserId, normalizedRole, machineContext]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>

View file

@ -39,6 +39,11 @@ export const auth = betterAuth({
type: "string",
required: false,
},
machinePersona: {
type: "string",
required: false,
input: false,
},
},
},
session: {
@ -76,6 +81,7 @@ export const auth = betterAuth({
role: ((user as { role?: string }).role ?? "agent").toLowerCase(),
tenantId: (user as { tenantId?: string | null }).tenantId ?? null,
avatarUrl: (user as { avatarUrl?: string | null }).avatarUrl ?? null,
machinePersona: (user as { machinePersona?: string | null }).machinePersona ?? null,
},
}
}),

View file

@ -1,7 +1,8 @@
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator"] as const
const ADMIN_ROLE = "admin"
const STAFF_ROLES = new Set(["admin", "manager", "agent", "collaborator"])
const PORTAL_ROLE = "collaborator"
const STAFF_ROLES = new Set(["admin", "manager", "agent"])
export type RoleOption = (typeof ROLE_OPTIONS)[number]
@ -16,3 +17,7 @@ export function isAdmin(role?: string | null) {
export function isStaff(role?: string | null) {
return STAFF_ROLES.has(normalizeRole(role) ?? "")
}
export function isPortalUser(role?: string | null) {
return normalizeRole(role) === PORTAL_ROLE
}

View file

@ -6,10 +6,11 @@ type EnsureMachineAccountParams = {
tenantId: string
hostname: string
machineToken: string
persona?: string
}
export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
const { machineId, tenantId, hostname, machineToken } = params
const { machineId, tenantId, hostname, machineToken, persona } = params
const machineEmail = `machine-${machineId}@machines.local`
const context = await auth.$context
@ -22,12 +23,14 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
name: machineName,
tenantId,
role: "machine",
machinePersona: persona ?? null,
},
create: {
email: machineEmail,
name: machineName,
role: "machine",
tenantId,
machinePersona: persona ?? null,
},
})

View file

@ -19,6 +19,11 @@ export type MachineSessionContext = {
companyId: Id<"companies"> | null
companySlug: string | null
metadata: Record<string, unknown> | null
persona: string | null
assignedUserId: Id<"users"> | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
}
headers: Headers
response: unknown
@ -41,6 +46,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID,
hostname: resolved.machine.hostname,
machineToken,
persona: (resolved.machine.persona ?? null) ?? undefined,
})
await client.mutation(api.machines.linkAuthAccount, {
@ -73,6 +79,11 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
companyId: (resolved.machine.companyId ?? null) as Id<"companies"> | null,
companySlug: resolved.machine.companySlug ?? null,
metadata: (resolved.machine.metadata ?? null) as Record<string, unknown> | null,
persona: (resolved.machine.persona ?? null) as string | null,
assignedUserId: (resolved.machine.assignedUserId ?? null) as Id<"users"> | null,
assignedUserEmail: resolved.machine.assignedUserEmail ?? null,
assignedUserName: resolved.machine.assignedUserName ?? null,
assignedUserRole: resolved.machine.assignedUserRole ?? null,
},
headers: signIn.headers,
response: signIn.response,