diff --git a/agents.md b/agents.md index f1e4d1c..d21b4a2 100644 --- a/agents.md +++ b/agents.md @@ -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` diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png index 6be5e50..5f7c295 100644 Binary files a/apps/desktop/src-tauri/icons/128x128.png and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png index e81bece..b2492e1 100644 Binary files a/apps/desktop/src-tauri/icons/128x128@2x.png and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png index a437dd5..8198d04 100644 Binary files a/apps/desktop/src-tauri/icons/32x32.png and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000..3551a05 Binary files /dev/null and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png index 0ca4f27..0c6bc5e 100644 Binary files a/apps/desktop/src-tauri/icons/Square107x107Logo.png and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png index b81f820..137f727 100644 Binary files a/apps/desktop/src-tauri/icons/Square142x142Logo.png and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png index 624c7bf..0f0f358 100644 Binary files a/apps/desktop/src-tauri/icons/Square150x150Logo.png and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png index c021d2b..410a66c 100644 Binary files a/apps/desktop/src-tauri/icons/Square284x284Logo.png and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png index 6219700..1f7216d 100644 Binary files a/apps/desktop/src-tauri/icons/Square30x30Logo.png and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png index f9bc048..b9a10ac 100644 Binary files a/apps/desktop/src-tauri/icons/Square310x310Logo.png and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png index d5fbfb2..46a575a 100644 Binary files a/apps/desktop/src-tauri/icons/Square44x44Logo.png and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png index 63440d7..a595168 100644 Binary files a/apps/desktop/src-tauri/icons/Square71x71Logo.png and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png index f3f705a..535a8cc 100644 Binary files a/apps/desktop/src-tauri/icons/Square89x89Logo.png and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png index 4556388..b40a8ad 100644 Binary files a/apps/desktop/src-tauri/icons/StoreLogo.png and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns index 12a5bce..1e87022 100644 Binary files a/apps/desktop/src-tauri/icons/icon.icns and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index b3636e4..b1471cf 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index e1cd261..b1c7cf7 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index d0d9b22..fe01a2b 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -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", diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index f96c5ca..8ea6fe0 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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 = { + 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("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 = { ...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 { - setUpdating(true) + 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() - const { relaunch } = await import("@tauri-apps/plugin-process") - await relaunch() - } else { - alert("Nenhuma atualização disponível.") + type UpdateResult = { + available?: boolean + version?: string + downloadAndInstall?: () => Promise + } + 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 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 (
@@ -345,6 +422,20 @@ function App() { setCompany(e.target.value)} />
+
+ + +

+ Colaboradores veem apenas seus chamados. Gestores acompanham todos os tickets da empresa. +

+
setCollabEmail(e.target.value)} /> @@ -442,9 +533,25 @@ function App() {
- + {updateInfo ? ( +
+ {updateInfo.message} +
+ ) : null}
@@ -452,7 +559,7 @@ function App() { )}
- Logotipo Rever Tecnologia + Logotipo Raven
) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 7b8d65b..9c1d6d2 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; diff --git a/convex/machines.ts b/convex/machines.ts index f30f8ff..db67369 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -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 = { + 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") } diff --git a/convex/migrations.ts b/convex/migrations.ts index 5a27e1e..bd7e164 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -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() diff --git a/convex/rbac.ts b/convex/rbac.ts index 89fb981..f224e51 100644 --- a/convex/rbac.ts +++ b/convex/rbac.ts @@ -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 diff --git a/convex/schema.ts b/convex/schema.ts index 099f674..7968186 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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(), diff --git a/convex/tickets.ts b/convex/tickets.ts index c5805ad..35ea9d2 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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 = { NO_CONTACT: "Falta de contato", WAITING_THIRD_PARTY: "Aguardando terceiro", diff --git a/convex/users.ts b/convex/users.ts index 59eb77c..414bbec 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -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); }, diff --git a/docs/DEV.md b/docs/DEV.md index 7e87410..a10c4c0 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -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). - diff --git a/docs/admin-inventory-ui.md b/docs/admin-inventory-ui.md index 30fb9b3..bf64b39 100644 --- a/docs/admin-inventory-ui.md +++ b/docs/admin-inventory-ui.md @@ -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). diff --git a/docs/desktop-updater.md b/docs/desktop-updater.md new file mode 100644 index 0000000..b6b6cac --- /dev/null +++ b/docs/desktop-updater.md @@ -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": "" + } + } + } + ``` + +--- + +## 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="" + ``` + > 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": "", + "url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven_0.1.6_x64-setup.exe" + }, + "linux-x86_64": { + "signature": "", + "url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven_0.1.6_amd64.AppImage" + }, + "darwin-x86_64": { + "signature": "", + "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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 7c7f05b..9c851f6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,8 @@ const eslintConfig = [ ".next/**", "out/**", "build/**", + "apps/desktop/dist/**", + "apps/desktop/src-tauri/target/**", "next-env.d.ts", "convex/_generated/**", ], diff --git a/middleware.ts b/middleware.ts index 86c886f..c7f08d0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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) { diff --git a/prisma/migrations/20251012062518_add_machine_persona/migration.sql b/prisma/migrations/20251012062518_add_machine_persona/migration.sql new file mode 100644 index 0000000..ebef301 --- /dev/null +++ b/prisma/migrations/20251012062518_add_machine_persona/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "AuthUser" ADD COLUMN "machinePersona" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f062284..89a72a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/public/raven.png b/public/raven.png new file mode 100644 index 0000000..b1c7cf7 Binary files /dev/null and b/public/raven.png differ diff --git a/src/app/api/admin/machines/access/route.ts b/src/app/api/admin/machines/access/route.ts new file mode 100644 index 0000000..1393777 --- /dev/null +++ b/src/app/api/admin/machines/access/route.ts @@ -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 + 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 }) + } +} diff --git a/src/app/api/machines/register/route.ts b/src/app/api/machines/register/route.ts index d7f6ecb..5579036 100644 --- a/src/app/api/machines/register/route.ts +++ b/src/app/api/machines/register/route.ts @@ -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 | undefined = payload.metadata + ? { ...(payload.metadata as Record) } + : 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"), diff --git a/src/app/api/machines/session/route.ts b/src/app/api/machines/session/route.ts new file mode 100644 index 0000000..11f54a0 --- /dev/null +++ b/src/app/api/machines/session/route.ts @@ -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 | 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 }) + } +} diff --git a/src/app/api/machines/sessions/route.ts b/src/app/api/machines/sessions/route.ts index a11d3a7..756d184 100644 --- a/src/app/api/machines/sessions/route.ts +++ b/src/app/api/machines/sessions/route.ts @@ -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 diff --git a/src/app/api/tickets/[id]/export/pdf/route.ts b/src/app/api/tickets/[id]/export/pdf/route.ts index 9ea3b1c..a27665a 100644 --- a/src/app/api/tickets/[id]/export/pdf/route.ts +++ b/src/app/api/tickets/[id]/export/pdf/route.ts @@ -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 }) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6915eb3..a088d2c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -21,7 +21,7 @@ export const metadata: Metadata = { title: "Raven", description: "Plataforma Raven da Rever", icons: { - icon: "/rever-8.png", + icon: "/raven.png", }, } diff --git a/src/app/login/login-page-client.tsx b/src/app/login/login-page-client.tsx index 6757b57..dbd2c59 100644 --- a/src/app/login/login-page-client.tsx +++ b/src/app/login/login-page-client.tsx @@ -53,7 +53,7 @@ export function LoginPageClient() {
Logotipo Rever Tecnologia 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)[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 const c = inv["collaborator"] - if (c && typeof c === "object") return c as Collaborator + if (c && typeof c === "object") { + const base = c as Record + 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(collaborator?.email ?? "") + const [accessName, setAccessName] = useState(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 ( @@ -594,17 +652,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) { ) : null} {collaborator?.email ? ( - Colaborador: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email} + {personaLabel}: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email} ) : null}
-
- {machine.authEmail ? ( - ) : null} + {machine.registeredBy ? ( Registrada via {machine.registeredBy} @@ -653,6 +715,42 @@ export function MachineDetails({ machine }: MachineDetailsProps) { + + + + Ajustar acesso da máquina + +
+
+ + +
+
+ + setAccessEmail(e.target.value)} placeholder="colaborador@empresa.com" /> +
+
+ + setAccessName(e.target.value)} placeholder="Nome completo" /> +
+
+
+ + +
+
+
+

Sincronização

@@ -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).collaborator if (!raw || typeof raw !== "object") return null const obj = raw as Record 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
{collaborator?.email ? (

- {collaborator.name ? `${collaborator.name} · ` : ""} + {persona}: {collaborator.name ? `${collaborator.name} · ` : ""} {collaborator.email}

) : null} diff --git a/src/components/portal/portal-shell.tsx b/src/components/portal/portal-shell.tsx index bcf6c84..39effb5 100644 --- a/src/components/portal/portal-shell.tsx +++ b/src/components/portal/portal-shell.tsx @@ -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) {
- + {initials}
- {session?.user.name ?? "Cliente"} - {session?.user.email ?? ""} + {displayName} + {displayEmail} + {machineContext ? ( + {personaLabel} + ) : null}