("collect_machine_profile")
+}
+
+async function startHeartbeat(config: AgentConfig) {
+ await invoke("start_machine_agent", {
+ baseUrl: config.apiBaseUrl,
+ token: config.machineToken,
+ status: "online",
+ intervalSeconds: 300,
+ })
+}
+
+async function stopHeartbeat() {
+ await invoke("stop_machine_agent")
+}
+
+function formatBytes(bytes: number) {
+ if (!bytes || Number.isNaN(bytes)) return "—"
+ const units = ["B", "KB", "MB", "GB", "TB"]
+ let value = bytes
+ let index = 0
+ while (value >= 1024 && index < units.length - 1) {
+ value /= 1024
+ index += 1
+ }
+ return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`
+}
+
+function formatPercent(value: number) {
+ if (Number.isNaN(value)) return "—"
+ return `${value.toFixed(1)}%`
+}
+
+function formatDate(timestamp?: number | null) {
+ if (!timestamp) return "—"
+ try {
+ return new Date(timestamp).toLocaleString()
+ } catch {
+ return "—"
+ }
+}
+
+function renderMachineSummary(profile: MachineProfile) {
+ if (!contentElement) return
+ const macs = profile.macAddresses.length > 0 ? profile.macAddresses.join(", ") : "—"
+ const serials = profile.serialNumbers.length > 0 ? profile.serialNumbers.join(", ") : "—"
+ const metrics = profile.metrics
+ const lastCollection = metrics.collectedAt ? new Date(metrics.collectedAt).toLocaleString() : "—"
+
+ return `
+
+
Hostname: ${profile.hostname}
+
Sistema: ${profile.os.name}${profile.os.version ? ` ${profile.os.version}` : ""} (${profile.os.architecture ?? "?"})
+
Endereços MAC: ${macs}
+
Identificadores: ${serials}
+
CPU: ${metrics.cpuPhysicalCores ?? metrics.cpuLogicalCores} núcleos · uso ${formatPercent(metrics.cpuUsagePercent)}
+
Memória: ${formatBytes(metrics.memoryUsedBytes)} / ${formatBytes(metrics.memoryTotalBytes)} (${formatPercent(metrics.memoryUsedPercent)})
+
Coletado em: ${lastCollection}
+
+ `
+}
+
+function renderRegistered(config: AgentConfig) {
+ if (!contentElement) return
+ const summaryHtml = `
+
+
ID da máquina: ${config.machineId}
+
Email vinculado: ${config.machineEmail ?? "—"}
+
Tenant: ${config.tenantId ?? "padrão"}
+
Empresa: ${config.companySlug ?? "não vinculada"}
+
Token expira em: ${formatDate(config.expiresAt)}
+
Última sincronização: ${formatDate(config.lastSyncedAt)}
+
Ambiente: ${config.appUrl}
+
+ `
+
+ contentElement.innerHTML = `
+ Esta máquina já está provisionada e com heartbeat ativo.
+ ${summaryHtml}
+
+
+
+
+ `
+
+ const openButton = document.getElementById("open-app")
+ const resetButton = document.getElementById("reset-agent")
+
+ openButton?.addEventListener("click", () => redirectToApp(config))
+ resetButton?.addEventListener("click", async () => {
+ await stopHeartbeat().catch(() => undefined)
+ await clearConfig()
+ setAlert("Configuração removida. Reiniciando fluxo de provisionamento.", "success")
+ setTimeout(() => window.location.reload(), 600)
+ })
+
+ setStatus("Máquina provisionada. Redirecionando para a interface web…")
+ setTimeout(() => redirectToApp(config), 1500)
+}
+
+function renderProvisionForm(profile: MachineProfile) {
+ if (!contentElement) return
+
+ const summary = renderMachineSummary(profile) ?? ""
+
+ contentElement.innerHTML = `
+
+ ${summary}
+ `
+
+ const form = document.getElementById("provision-form") as HTMLFormElement | null
+ const refreshButton = document.getElementById("refresh-profile")
+
+ form?.addEventListener("submit", (event) => {
+ event.preventDefault()
+ handleRegister(profile, form)
+ })
+
+ refreshButton?.addEventListener("click", async () => {
+ setStatus("Recolhendo informações atualizadas da máquina…")
+ try {
+ const updatedProfile = await collectMachineProfile()
+ renderProvisionForm(updatedProfile)
+ setStatus("Dados atualizados. Revise e confirme o provisionamento.")
+ } catch (error) {
+ console.error("[agent] Falha ao atualizar perfil da máquina", error)
+ setAlert("Não foi possível atualizar as informações da máquina.", "error")
+ }
+ })
+}
+
+async function handleRegister(profile: MachineProfile, form: HTMLFormElement) {
+ const submitButton = form.querySelector("button[type=submit]") as HTMLButtonElement | null
+ const formData = new FormData(form)
+ const provisioningSecret = (formData.get("provisioningSecret") as string | null)?.trim()
+ const tenantId = (formData.get("tenantId") as string | null)?.trim()
+ const companySlug = (formData.get("companySlug") as string | null)?.trim()
+
+ if (!provisioningSecret) {
+ setAlert("Informe o código de provisionamento.", "error")
+ return
+ }
+
+ try {
+ if (submitButton) {
+ submitButton.disabled = true
+ }
+ setAlert(null)
+ setStatus("Enviando dados de registro da máquina…")
+
+ const payload = {
+ provisioningSecret,
+ tenantId: tenantId && tenantId.length > 0 ? tenantId : undefined,
+ companySlug: companySlug && companySlug.length > 0 ? companySlug : undefined,
+ hostname: profile.hostname,
+ os: profile.os,
+ macAddresses: profile.macAddresses,
+ serialNumbers: profile.serialNumbers,
+ metadata: {
+ inventory: profile.inventory,
+ metrics: profile.metrics,
+ },
+ registeredBy: "desktop-agent",
+ }
+
+ const response = await fetch(`${apiBaseUrl}/api/machines/register`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ })
+
+ if (!response.ok) {
+ let message = `Falha ao registrar máquina (${response.status})`
+ try {
+ const errorBody = await response.json()
+ if (errorBody?.error) {
+ message = errorBody.error
+ }
+ } catch {
+ // ignore
+ }
+ throw new Error(message)
+ }
+
+ const data = (await response.json()) as MachineRegisterResponse
+ const config: AgentConfig = {
+ machineId: data.machineId,
+ machineToken: data.machineToken,
+ tenantId: data.tenantId ?? null,
+ companySlug: data.companySlug ?? null,
+ machineEmail: data.machineEmail ?? null,
+ apiBaseUrl,
+ appUrl,
+ createdAt: Date.now(),
+ lastSyncedAt: Date.now(),
+ expiresAt: data.expiresAt ?? null,
+ }
+
+ await saveConfig(config)
+ await startHeartbeat(config)
+
+ setAlert("Máquina registrada com sucesso! Abrindo a interface web…", "success")
+ setStatus("Autenticando dispositivo e abrindo o Sistema de Chamados.")
+
+ setTimeout(() => redirectToApp(config), 800)
+ } catch (error) {
+ console.error("[agent] Erro no registro da máquina", error)
+ const message = error instanceof Error ? error.message : "Erro desconhecido ao registrar a máquina."
+ const normalized = message.toLowerCase()
+ if (normalized.includes("failed to fetch") || normalized.includes("load failed") || normalized.includes("network")) {
+ setAlert("Não foi possível se conectar ao servidor. Verifique a conexão e o endereço configurado.", "error")
+ } else if (normalized.includes("401") || normalized.includes("403") || normalized.includes("código de provisionamento inválido")) {
+ setAlert("Código de provisionamento inválido. Confirme o segredo configurado no servidor e tente novamente.", "error")
+ } else {
+ setAlert(message, "error")
+ }
+ setStatus("Revise os dados e tente novamente.")
+ if (submitButton) {
+ submitButton.disabled = false
+ }
+ }
+}
+
+function redirectToApp(config: AgentConfig) {
+ const url = `${config.appUrl}/machines/handshake?token=${encodeURIComponent(config.machineToken)}`
+ window.location.replace(url)
+}
+
+async function ensureHeartbeat(config: AgentConfig): Promise {
+ const adjustedConfig = {
+ ...config,
+ apiBaseUrl,
+ appUrl,
+ lastSyncedAt: Date.now(),
+ }
+
+ await saveConfig(adjustedConfig)
+ await startHeartbeat(adjustedConfig)
+
+ return adjustedConfig
+}
+
+async function bootstrap() {
+ setStatus("Iniciando agente desktop…")
+ setAlert(null)
+
+ try {
+ const stored = await loadConfig()
+ if (stored?.machineToken) {
+ const updated = await ensureHeartbeat(stored)
+ renderRegistered(updated)
+ return
+ }
+ } catch (error) {
+ console.error("[agent] Falha ao iniciar com configuração existente", error)
+ setAlert("Não foi possível carregar a configuração armazenada. Você poderá reprovisionar abaixo.", "error")
+ }
+
+ try {
+ setStatus("Coletando informações básicas da máquina…")
+ const profile = await collectMachineProfile()
+ renderProvisionForm(profile)
+ setStatus("Informe o código de provisionamento para registrar esta máquina.")
+ } catch (error) {
+ console.error("[agent] Falha ao coletar dados da máquina", error)
+ setAlert("Não foi possível coletar dados da máquina. Verifique permissões do sistema e tente novamente.", "error")
+ setStatus("Interação necessária para continuar.")
+ }
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ void bootstrap()
+})
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 7011746..80776e1 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -1,116 +1,235 @@
-.logo.vite:hover {
- filter: drop-shadow(0 0 2em #747bff);
-}
-
-.logo.typescript:hover {
- filter: drop-shadow(0 0 2em #2d79c7);
-}
:root {
- font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ color-scheme: light dark;
+ font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
- line-height: 24px;
- font-weight: 400;
-
- color: #0f0f0f;
- background-color: #f6f6f6;
-
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- -webkit-text-size-adjust: 100%;
-}
-
-.container {
+ line-height: 1.5;
+ background-color: #f1f5f9;
+ color: #0f172a;
margin: 0;
- padding-top: 10vh;
- display: flex;
- flex-direction: column;
- justify-content: center;
- text-align: center;
}
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: 0.75s;
+body {
+ margin: 0;
}
-.logo.tauri:hover {
- filter: drop-shadow(0 0 2em #24c8db);
+.app-root {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: radial-gradient(circle at top, rgba(59, 130, 246, 0.12), transparent 60%),
+ radial-gradient(circle at bottom, rgba(16, 185, 129, 0.12), transparent 55%);
}
-.row {
- display: flex;
- justify-content: center;
+.card {
+ width: min(440px, 100%);
+ background-color: rgba(255, 255, 255, 0.85);
+ border-radius: 16px;
+ box-shadow: 0 16px 60px rgba(15, 23, 42, 0.16);
+ padding: 28px;
+ backdrop-filter: blur(12px);
}
-a {
+.card header h1 {
+ margin: 0;
+ font-size: 1.75rem;
+}
+
+.subtitle {
+ margin: 4px 0 0;
+ color: #475569;
+ font-size: 0.95rem;
+}
+
+.alert {
+ margin-top: 16px;
+ font-size: 0.95rem;
+ color: #0f172a;
+ background-color: #e0f2fe;
+ border-radius: 12px;
+ padding: 12px 14px;
+ display: none;
+}
+
+.alert.visible {
+ display: block;
+}
+
+.alert.error {
+ background-color: #fee2e2;
+ color: #b91c1c;
+}
+
+.alert.success {
+ background-color: #dcfce7;
+ color: #166534;
+}
+
+form {
+ display: grid;
+ gap: 12px;
+ margin-top: 18px;
+}
+
+label {
+ display: grid;
+ gap: 6px;
font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
+ color: #0f172a;
}
-a:hover {
- color: #535bf2;
-}
-
-h1 {
- text-align: center;
+label span.optional {
+ font-weight: 400;
+ color: #64748b;
+ font-size: 0.85rem;
}
input,
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- color: #0f0f0f;
- background-color: #ffffff;
- transition: border-color 0.25s;
- box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
+select {
+ padding: 10px 12px;
+ border-radius: 10px;
+ border: 1px solid rgba(148, 163, 184, 0.6);
+ font-size: 1rem;
+ background-color: rgba(241, 245, 249, 0.8);
+ color: inherit;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
-button {
- cursor: pointer;
-}
-
-button:hover {
- border-color: #396cd8;
-}
-button:active {
- border-color: #396cd8;
- background-color: #e8e8e8;
-}
-
-input,
-button {
+input:focus,
+select:focus {
outline: none;
+ border-color: #2563eb;
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}
-#greet-input {
- margin-right: 5px;
+button {
+ padding: 12px 16px;
+ border-radius: 12px;
+ border: none;
+ background-color: #2563eb;
+ color: #ffffff;
+ font-weight: 600;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background-color 0.2s ease, transform 0.2s ease;
+}
+
+button.secondary {
+ background: none;
+ color: #2563eb;
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+button:hover:not(:disabled) {
+ background-color: #1d4ed8;
+ transform: translateY(-1px);
+}
+
+.machine-summary {
+ margin-top: 18px;
+ padding: 14px;
+ border-radius: 12px;
+ background-color: rgba(15, 23, 42, 0.05);
+ display: grid;
+ gap: 8px;
+ font-size: 0.95rem;
+}
+
+.machine-summary strong {
+ font-weight: 600;
+}
+
+.actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-top: 20px;
+}
+
+.actions button {
+ flex: 1;
+}
+
+.status-text {
+ margin-top: 16px;
+ font-size: 0.95rem;
+ color: #334155;
+}
+
+.spinner {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 3px solid rgba(37, 99, 235, 0.14);
+ border-top-color: #2563eb;
+ animation: spin 0.8s linear infinite;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
}
@media (prefers-color-scheme: dark) {
:root {
- color: #f6f6f6;
- background-color: #2f2f2f;
+ background-color: #0f172a;
+ color: #e2e8f0;
}
- a:hover {
- color: #24c8db;
+ .card {
+ background-color: rgba(15, 23, 42, 0.75);
+ color: #e2e8f0;
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.4);
+ }
+
+ .subtitle {
+ color: #94a3b8;
+ }
+
+ .alert {
+ background-color: rgba(37, 99, 235, 0.16);
+ color: #bfdbfe;
+ }
+
+ .alert.error {
+ background-color: rgba(248, 113, 113, 0.2);
+ color: #fecaca;
+ }
+
+ .alert.success {
+ background-color: rgba(34, 197, 94, 0.18);
+ color: #bbf7d0;
}
input,
- button {
- color: #ffffff;
- background-color: #0f0f0f98;
+ select {
+ background-color: rgba(15, 23, 42, 0.5);
+ border-color: rgba(148, 163, 184, 0.35);
}
- button:active {
- background-color: #0f0f0f69;
+
+ input:focus,
+ select:focus {
+ border-color: #60a5fa;
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.32);
+ }
+
+ button.secondary {
+ color: #93c5fd;
+ }
+
+ .machine-summary {
+ background-color: rgba(148, 163, 184, 0.12);
+ }
+
+ .status-text {
+ color: #cbd5f5;
}
}
diff --git a/docs/plano-app-desktop-maquinas.md b/docs/plano-app-desktop-maquinas.md
index 1013118..fce8da4 100644
--- a/docs/plano-app-desktop-maquinas.md
+++ b/docs/plano-app-desktop-maquinas.md
@@ -43,14 +43,20 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
## Notas de Implementação (Atual)
- Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`.
-- `src/main.ts` redireciona a WebView para `VITE_APP_URL` (padrão `http://localhost:3000`), reaproveitando a UI Next web.
-- `index.html` exibe fallback simples enquanto o Next inicializa.
-- Necessário criar `.env` em `apps/desktop` (ou usar variáveis de ambiente) com `VITE_APP_URL` correspondente ao ambiente.
+- O agente desktop agora possui fluxo próprio: coleta inventário local via comandos Rust, solicita o código de provisionamento, registra a máquina e inicia heartbeats periódicos (`src-tauri/src/agent.rs` + `src/main.ts`).
+- Formulário inicial exibe resumo de hardware/OS e salva o token em `~/.config/Sistema de Chamados Desktop/machine-agent.json` (ou equivalente por SO) para reaproveitamento em relançamentos.
+- URLs configuráveis via `.env` do app desktop:
+ - `VITE_APP_URL` → aponta para a interface Next (padrao produção: `https://tickets.esdrasrenan.com.br`).
+ - `VITE_API_BASE_URL` → base usada nas chamadas REST (`/api/machines/*`), normalmente igual ao `APP_URL`.
+- Após provisionar ou encontrar token válido, o agente dispara `/machines/handshake?token=...` que autentica a máquina no Better Auth, devolve cookies e redireciona para a UI.
+- `apps/desktop/src-tauri/tauri.conf.json` ajustado para rodar `pnpm run dev/build`, servir `dist/` e abrir janela 1100x720.
- Novas tabelas Convex: `machines` (fingerprint, heartbeat, vínculo com AuthUser) e `machineTokens` (hash + TTL).
- Novos endpoints Next:
- `POST /api/machines/register` — provisiona máquina, gera token e usuário Better Auth (role `machine`).
- `POST /api/machines/heartbeat` — atualiza estado, métricas e renova TTL.
- `POST /api/machines/sessions` — troca `machineToken` por sessão Better Auth e devolve cookies.
+- As rotas `/api/machines/*` respondem a preflight `OPTIONS` com CORS liberado para o agente (`https://tickets.esdrasrenan.com.br`, `tauri://localhost`, `http://localhost:1420`).
+- Rota `GET /machines/handshake` realiza o login automático da máquina (seta cookies e redireciona).
- Webhook FleetDM: `POST /api/integrations/fleet/hosts` (header `x-fleet-secret`) sincroniza inventário/métricas utilizando `machines.upsertInventory`.
- Script `ensureMachineAccount` garante usuário `AuthUser` e senha sincronizada com o token atual.
- Variáveis `.env` novas: `MACHINE_PROVISIONING_SECRET` (obrigatória) e `MACHINE_TOKEN_TTL_MS` (opcional, padrão 30 dias).
@@ -71,4 +77,5 @@ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
---
> Histórico de atualizações:
+> - 2025-02-20 — Fluxo completo do agente desktop, heartbeats e rota `/machines/handshake` documentados (assistente).
> - 2025-02-14 — Documento criado com visão geral e plano macro (assistente).
diff --git a/package.json b/package.json
index 5959c60..2650f44 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,9 @@
"convex:dev": "convex dev",
"test": "vitest",
"auth:seed": "node scripts/seed-auth.mjs",
- "queues:ensure": "node scripts/ensure-default-queues.mjs"
+ "queues:ensure": "node scripts/ensure-default-queues.mjs",
+ "desktop:dev": "pnpm --filter appsdesktop tauri dev",
+ "desktop:build": "pnpm --filter appsdesktop tauri build"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2dc564f..2acfa54 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -195,6 +195,9 @@ importers:
eslint-config-next:
specifier: 15.5.4
version: 15.5.4(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
+ eslint-plugin-react-hooks:
+ specifier: ^5.0.0
+ version: 5.2.0(eslint@9.37.0(jiti@2.6.1))
prisma:
specifier: ^6.16.2
version: 6.16.3(typescript@5.9.3)
@@ -211,6 +214,28 @@ importers:
specifier: ^2.1.4
version: 2.1.9(@types/node@20.19.19)(lightningcss@1.30.1)
+ apps/desktop:
+ dependencies:
+ '@tauri-apps/api':
+ specifier: ^2
+ version: 2.8.0
+ '@tauri-apps/plugin-opener':
+ specifier: ^2
+ version: 2.5.0
+ '@tauri-apps/plugin-store':
+ specifier: ^2
+ version: 2.4.0
+ devDependencies:
+ '@tauri-apps/cli':
+ specifier: ^2
+ version: 2.8.4
+ typescript:
+ specifier: ~5.6.2
+ version: 5.6.3
+ vite:
+ specifier: ^6.0.3
+ version: 6.3.6(@types/node@20.19.19)(jiti@2.6.1)(lightningcss@1.30.1)
+
packages:
'@alloc/quick-lru@5.2.0':
@@ -1728,6 +1753,12 @@ packages:
engines: {node: '>= 10'}
hasBin: true
+ '@tauri-apps/plugin-opener@2.5.0':
+ resolution: {integrity: sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA==}
+
+ '@tauri-apps/plugin-store@2.4.0':
+ resolution: {integrity: sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A==}
+
'@tiptap/core@3.6.5':
resolution: {integrity: sha512-CgXuhevQbBcPfxaXzGZgIY9+aVMSAd68Q21g3EONz1iZBw026QgiaLhGK6jgGTErZL4GoNL/P+gC5nFCvN7+cA==}
peerDependencies:
@@ -3938,6 +3969,11 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
+ typescript@5.6.3:
+ resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -4049,6 +4085,46 @@ packages:
terser:
optional: true
+ vite@6.3.6:
+ resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
vitest@2.1.9:
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -5432,6 +5508,14 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.8.4
'@tauri-apps/cli-win32-x64-msvc': 2.8.4
+ '@tauri-apps/plugin-opener@2.5.0':
+ dependencies:
+ '@tauri-apps/api': 2.8.0
+
+ '@tauri-apps/plugin-store@2.4.0':
+ dependencies:
+ '@tauri-apps/api': 2.8.0
+
'@tiptap/core@3.6.5(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/pm': 3.6.5
@@ -7982,6 +8066,8 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
+ typescript@5.6.3: {}
+
typescript@5.9.3: {}
uc.micro@2.1.0: {}
@@ -8115,6 +8201,20 @@ snapshots:
fsevents: 2.3.3
lightningcss: 1.30.1
+ vite@6.3.6(@types/node@20.19.19)(jiti@2.6.1)(lightningcss@1.30.1):
+ dependencies:
+ esbuild: 0.25.4
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.52.4
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 20.19.19
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.30.1
+
vitest@2.1.9(@types/node@20.19.19)(lightningcss@1.30.1):
dependencies:
'@vitest/expect': 2.1.9
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d275163..b4c4e18 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,5 +1,6 @@
packages:
- .
+ - apps/desktop
ignoredBuiltDependencies:
- '@prisma/client'
diff --git a/src/app/api/machines/heartbeat/route.ts b/src/app/api/machines/heartbeat/route.ts
index cfd8b17..8ccc3fa 100644
--- a/src/app/api/machines/heartbeat/route.ts
+++ b/src/app/api/machines/heartbeat/route.ts
@@ -1,9 +1,9 @@
-import { NextResponse } from "next/server"
import { z } from "zod"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { env } from "@/lib/env"
+import { createCorsPreflight, jsonWithCors } from "@/server/cors"
const heartbeatSchema = z.object({
machineToken: z.string().min(1),
@@ -21,14 +21,20 @@ const heartbeatSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
})
+const CORS_METHODS = "POST, OPTIONS"
+
+export async function OPTIONS(request: Request) {
+ return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
+}
+
export async function POST(request: Request) {
if (request.method !== "POST") {
- return NextResponse.json({ error: "Método não permitido" }, { status: 405 })
+ return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS)
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
- return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
+ return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS)
}
let payload
@@ -36,16 +42,21 @@ export async function POST(request: Request) {
const raw = await request.json()
payload = heartbeatSchema.parse(raw)
} catch (error) {
- return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
+ return jsonWithCors(
+ { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
+ 400,
+ request.headers.get("origin"),
+ CORS_METHODS
+ )
}
const client = new ConvexHttpClient(convexUrl)
try {
const response = await client.mutation(api.machines.heartbeat, payload)
- return NextResponse.json(response)
+ return jsonWithCors(response, 200, request.headers.get("origin"), CORS_METHODS)
} catch (error) {
console.error("[machines.heartbeat] Falha ao registrar heartbeat", error)
- return NextResponse.json({ error: "Falha ao registrar heartbeat" }, { status: 500 })
+ return jsonWithCors({ error: "Falha ao registrar heartbeat" }, 500, request.headers.get("origin"), CORS_METHODS)
}
}
diff --git a/src/app/api/machines/register/route.ts b/src/app/api/machines/register/route.ts
index 3f14609..18cfae9 100644
--- a/src/app/api/machines/register/route.ts
+++ b/src/app/api/machines/register/route.ts
@@ -1,4 +1,3 @@
-import { NextResponse } from "next/server"
import { z } from "zod"
import { ConvexHttpClient } from "convex/browser"
@@ -7,6 +6,7 @@ import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { ensureMachineAccount } from "@/server/machines-auth"
+import { createCorsPreflight, jsonWithCors } from "@/server/cors"
const registerSchema = z
.object({
@@ -29,14 +29,20 @@ const registerSchema = z
{ message: "Informe ao menos um MAC address ou número de série" }
)
+const CORS_METHODS = "POST, OPTIONS"
+
+export async function OPTIONS(request: Request) {
+ return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
+}
+
export async function POST(request: Request) {
if (request.method !== "POST") {
- return NextResponse.json({ error: "Método não permitido" }, { status: 405 })
+ return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS)
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
- return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
+ return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS)
}
let payload
@@ -44,7 +50,12 @@ export async function POST(request: Request) {
const raw = await request.json()
payload = registerSchema.parse(raw)
} catch (error) {
- return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
+ return jsonWithCors(
+ { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
+ 400,
+ request.headers.get("origin"),
+ CORS_METHODS
+ )
}
const client = new ConvexHttpClient(convexUrl)
@@ -75,7 +86,7 @@ export async function POST(request: Request) {
authEmail: account.authEmail,
})
- return NextResponse.json(
+ return jsonWithCors(
{
machineId: registration.machineId,
tenantId: registration.tenantId,
@@ -85,10 +96,12 @@ export async function POST(request: Request) {
machineEmail: account.authEmail,
expiresAt: registration.expiresAt,
},
- { status: 201 }
+ { status: 201 },
+ request.headers.get("origin"),
+ CORS_METHODS
)
} catch (error) {
console.error("[machines.register] Falha no provisionamento", error)
- return NextResponse.json({ error: "Falha ao provisionar máquina" }, { status: 500 })
+ return jsonWithCors({ error: "Falha ao provisionar máquina" }, 500, request.headers.get("origin"), CORS_METHODS)
}
}
diff --git a/src/app/api/machines/sessions/route.ts b/src/app/api/machines/sessions/route.ts
index acaf61a..a11d3a7 100644
--- a/src/app/api/machines/sessions/route.ts
+++ b/src/app/api/machines/sessions/route.ts
@@ -1,27 +1,22 @@
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 { env } from "@/lib/env"
-import { DEFAULT_TENANT_ID } from "@/lib/constants"
-import { ensureMachineAccount } from "@/server/machines-auth"
-import { auth } from "@/lib/auth"
+import { createMachineSession } from "@/server/machines-session"
+import { applyCorsHeaders, createCorsPreflight, jsonWithCors } from "@/server/cors"
const sessionSchema = z.object({
machineToken: z.string().min(1),
rememberMe: z.boolean().optional(),
})
+const CORS_METHODS = "POST, OPTIONS"
+
+export async function OPTIONS(request: Request) {
+ return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
+}
+
export async function POST(request: Request) {
if (request.method !== "POST") {
- return NextResponse.json({ error: "Método não permitido" }, { status: 405 })
- }
-
- const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
- if (!convexUrl) {
- return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
+ return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS)
}
let payload
@@ -29,68 +24,34 @@ export async function POST(request: Request) {
const raw = await request.json()
payload = sessionSchema.parse(raw)
} catch (error) {
- return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
+ return jsonWithCors(
+ { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
+ 400,
+ request.headers.get("origin"),
+ CORS_METHODS
+ )
}
- const client = new ConvexHttpClient(convexUrl)
-
try {
- const resolved = await client.mutation(api.machines.resolveToken, { machineToken: payload.machineToken })
- let machineEmail = resolved.machine.authEmail ?? null
-
- if (!machineEmail) {
- const account = await ensureMachineAccount({
- machineId: resolved.machine._id,
- tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID,
- hostname: resolved.machine.hostname,
- machineToken: payload.machineToken,
- })
-
- await client.mutation(api.machines.linkAuthAccount, {
- machineId: resolved.machine._id as Id<"machines">,
- authUserId: account.authUserId,
- authEmail: account.authEmail,
- })
-
- machineEmail = account.authEmail
- }
-
- const signIn = await auth.api.signInEmail({
- body: {
- email: machineEmail,
- password: payload.machineToken,
- rememberMe: payload.rememberMe ?? true,
- },
- returnHeaders: true,
- })
-
+ const session = await createMachineSession(payload.machineToken, payload.rememberMe ?? true)
const response = NextResponse.json(
{
ok: true,
- machine: {
- id: resolved.machine._id,
- hostname: resolved.machine.hostname,
- osName: resolved.machine.osName,
- osVersion: resolved.machine.osVersion,
- architecture: resolved.machine.architecture,
- status: resolved.machine.status,
- lastHeartbeatAt: resolved.machine.lastHeartbeatAt,
- companyId: resolved.machine.companyId,
- companySlug: resolved.machine.companySlug,
- metadata: resolved.machine.metadata,
- },
- session: signIn.response,
+ machine: session.machine,
+ session: session.response,
},
{ status: 200 }
)
- signIn.headers.forEach((value, key) => {
+ session.headers.forEach((value, key) => {
response.headers.set(key, value)
})
+ applyCorsHeaders(response, request.headers.get("origin"), CORS_METHODS)
+
return response
} catch (error) {
console.error("[machines.sessions] Falha ao criar sessão", error)
- return NextResponse.json({ error: "Falha ao autenticar máquina" }, { status: 500 })
+ return jsonWithCors({ error: "Falha ao autenticar máquina" }, 500, request.headers.get("origin"), CORS_METHODS)
}
}
diff --git a/src/app/machines/handshake/route.ts b/src/app/machines/handshake/route.ts
new file mode 100644
index 0000000..869457e
--- /dev/null
+++ b/src/app/machines/handshake/route.ts
@@ -0,0 +1,64 @@
+import { NextRequest, NextResponse } from "next/server"
+
+import { createMachineSession } from "@/server/machines-session"
+
+const ERROR_TEMPLATE = `
+
+
+
+
+
+ Falha na autenticação da máquina
+
+
+
+
+ Não foi possível autenticar esta máquina
+ O token informado é inválido, expirou ou não está mais associado a uma máquina ativa.
+ Volte ao agente desktop, gere um novo token ou realize o provisionamento novamente.
+ Voltar para o Sistema de Chamados
+
+
+
+`
+
+export async function GET(request: NextRequest) {
+ const token = request.nextUrl.searchParams.get("token")
+ if (!token) {
+ return NextResponse.redirect(new URL("/", request.nextUrl.origin))
+ }
+
+ const redirectParam = request.nextUrl.searchParams.get("redirect") ?? "/"
+ const redirectUrl = new URL(redirectParam, request.nextUrl.origin)
+
+ try {
+ const session = await createMachineSession(token, true)
+ const response = NextResponse.redirect(redirectUrl)
+
+ session.headers.forEach((value, key) => {
+ if (key.toLowerCase() === "set-cookie") {
+ response.headers.append("set-cookie", value)
+ } else {
+ response.headers.set(key, value)
+ }
+ })
+
+ return response
+ } catch (error) {
+ console.error("[machines.handshake] Falha ao autenticar máquina", error)
+ return new NextResponse(ERROR_TEMPLATE, {
+ status: 500,
+ headers: {
+ "Content-Type": "text/html; charset=utf-8",
+ },
+ })
+ }
+}
+
diff --git a/src/server/cors.ts b/src/server/cors.ts
new file mode 100644
index 0000000..2846563
--- /dev/null
+++ b/src/server/cors.ts
@@ -0,0 +1,38 @@
+import { NextResponse } from "next/server"
+
+const DEFAULT_ALLOWED_ORIGINS = [
+ process.env.NEXT_PUBLIC_APP_URL ?? null,
+ "https://tickets.esdrasrenan.com.br",
+ "http://localhost:1420",
+ "http://localhost:3000",
+ "tauri://localhost",
+].filter((value): value is string => Boolean(value))
+
+export function resolveCorsOrigin(requestOrigin: string | null): string {
+ if (!requestOrigin) return "*"
+ const allowed = new Set(DEFAULT_ALLOWED_ORIGINS)
+ if (allowed.has(requestOrigin)) {
+ return requestOrigin
+ }
+ return "*"
+}
+
+export function applyCorsHeaders(response: NextResponse, origin: string | null, methods = "POST, OPTIONS") {
+ const resolvedOrigin = resolveCorsOrigin(origin)
+ response.headers.set("Access-Control-Allow-Origin", resolvedOrigin)
+ response.headers.set("Access-Control-Allow-Methods", methods)
+ response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ response.headers.set("Access-Control-Max-Age", "86400")
+ response.headers.set("Vary", "Origin")
+ return response
+}
+
+export function createCorsPreflight(origin: string | null, methods = "POST, OPTIONS") {
+ const response = new NextResponse(null, { status: 204 })
+ return applyCorsHeaders(response, origin, methods)
+}
+
+export function jsonWithCors(data: T, init: number | ResponseInit, origin: string | null, methods = "POST, OPTIONS") {
+ const response = NextResponse.json(data, typeof init === "number" ? { status: init } : init)
+ return applyCorsHeaders(response, origin, methods)
+}
diff --git a/src/server/machines-session.ts b/src/server/machines-session.ts
new file mode 100644
index 0000000..a49d0cf
--- /dev/null
+++ b/src/server/machines-session.ts
@@ -0,0 +1,81 @@
+import { ConvexHttpClient } from "convex/browser"
+
+import { api } from "@/convex/_generated/api"
+import type { Id } from "@/convex/_generated/dataModel"
+import { DEFAULT_TENANT_ID } from "@/lib/constants"
+import { env } from "@/lib/env"
+import { ensureMachineAccount } from "@/server/machines-auth"
+import { auth } from "@/lib/auth"
+
+export type MachineSessionContext = {
+ machine: {
+ id: Id<"machines">
+ hostname: string
+ osName: string | null
+ osVersion: string | null
+ architecture: string | null
+ status: string | null
+ lastHeartbeatAt: number | null
+ companyId: Id<"companies"> | null
+ companySlug: string | null
+ metadata: Record | null
+ }
+ headers: Headers
+ response: unknown
+}
+
+export async function createMachineSession(machineToken: string, rememberMe = true): Promise {
+ const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
+ if (!convexUrl) {
+ throw new Error("Convex não configurado")
+ }
+
+ const client = new ConvexHttpClient(convexUrl)
+
+ const resolved = await client.mutation(api.machines.resolveToken, { machineToken })
+ let machineEmail = resolved.machine.authEmail ?? null
+
+ if (!machineEmail) {
+ const account = await ensureMachineAccount({
+ machineId: resolved.machine._id,
+ tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID,
+ hostname: resolved.machine.hostname,
+ machineToken,
+ })
+
+ await client.mutation(api.machines.linkAuthAccount, {
+ machineId: resolved.machine._id as Id<"machines">,
+ authUserId: account.authUserId,
+ authEmail: account.authEmail,
+ })
+
+ machineEmail = account.authEmail
+ }
+
+ const signIn = await auth.api.signInEmail({
+ body: {
+ email: machineEmail,
+ password: machineToken,
+ rememberMe,
+ },
+ returnHeaders: true,
+ })
+
+ return {
+ machine: {
+ id: resolved.machine._id as Id<"machines">,
+ hostname: resolved.machine.hostname,
+ osName: resolved.machine.osName,
+ osVersion: resolved.machine.osVersion,
+ architecture: resolved.machine.architecture,
+ status: resolved.machine.status,
+ lastHeartbeatAt: resolved.machine.lastHeartbeatAt,
+ companyId: (resolved.machine.companyId ?? null) as Id<"companies"> | null,
+ companySlug: resolved.machine.companySlug ?? null,
+ metadata: (resolved.machine.metadata ?? null) as Record | null,
+ },
+ headers: signIn.headers,
+ response: signIn.response,
+ }
+}
+