docs: registrar fluxo do updater e atualizar chaves
18
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`
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.1 KiB |
BIN
apps/desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1,020 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 50 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { Store } from "@tauri-apps/plugin-store"
|
||||
|
|
@ -45,6 +46,12 @@ type MachineRegisterResponse = {
|
|||
machineToken: string
|
||||
machineEmail?: string | null
|
||||
expiresAt?: number | null
|
||||
persona?: string | null
|
||||
assignedUserId?: string | null
|
||||
collaborator?: {
|
||||
email: string
|
||||
name?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type AgentConfig = {
|
||||
|
|
@ -54,6 +61,10 @@ type AgentConfig = {
|
|||
machineEmail?: string | null
|
||||
collaboratorEmail?: string | null
|
||||
collaboratorName?: string | null
|
||||
accessRole: "collaborator" | "manager"
|
||||
assignedUserId?: string | null
|
||||
assignedUserEmail?: string | null
|
||||
assignedUserName?: string | null
|
||||
apiBaseUrl: string
|
||||
appUrl: string
|
||||
createdAt: number
|
||||
|
|
@ -129,7 +140,14 @@ function App() {
|
|||
const [company, setCompany] = useState("")
|
||||
const [collabEmail, setCollabEmail] = useState("")
|
||||
const [collabName, setCollabName] = useState("")
|
||||
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">("collaborator")
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateInfo, setUpdateInfo] = useState<{ message: string; tone: "info" | "success" | "error" } | null>({
|
||||
message: "Atualizações automáticas são verificadas a cada inicialização.",
|
||||
tone: "info",
|
||||
})
|
||||
const autoLaunchRef = useRef(false)
|
||||
const autoUpdateRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
|
|
@ -140,6 +158,7 @@ function App() {
|
|||
setToken(t)
|
||||
const cfg = await readConfig(s)
|
||||
setConfig(cfg)
|
||||
setAccessRole(cfg?.accessRole ?? "collaborator")
|
||||
if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail)
|
||||
if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName)
|
||||
if (!t) {
|
||||
|
|
@ -147,7 +166,7 @@ function App() {
|
|||
setProfile(p)
|
||||
}
|
||||
setStatus(t ? "online" : null)
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Falha ao carregar estado do agente.")
|
||||
}
|
||||
})()
|
||||
|
|
@ -161,7 +180,8 @@ function App() {
|
|||
const normalizedName = name.length > 0 ? name : null
|
||||
if (
|
||||
config.collaboratorEmail === normalizedEmail &&
|
||||
config.collaboratorName === normalizedName
|
||||
config.collaboratorName === normalizedName &&
|
||||
config.accessRole === accessRole
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
@ -169,10 +189,11 @@ function App() {
|
|||
...config,
|
||||
collaboratorEmail: normalizedEmail,
|
||||
collaboratorName: normalizedName,
|
||||
accessRole,
|
||||
}
|
||||
setConfig(nextConfig)
|
||||
writeConfig(store, nextConfig).catch((err) => console.error("Falha ao atualizar colaborador", err))
|
||||
}, [store, config?.machineId, config?.collaboratorEmail, config?.collaboratorName, collabEmail, collabName])
|
||||
}, [store, config, config?.collaboratorEmail, config?.collaboratorName, config?.accessRole, collabEmail, collabName, accessRole])
|
||||
|
||||
useEffect(() => {
|
||||
if (!store || !config) return
|
||||
|
|
@ -195,7 +216,7 @@ function App() {
|
|||
return appUrl
|
||||
}
|
||||
return normalized
|
||||
}, [config?.appUrl, appUrl])
|
||||
}, [config?.appUrl])
|
||||
|
||||
async function register() {
|
||||
if (!profile) return
|
||||
|
|
@ -205,6 +226,16 @@ function App() {
|
|||
const collaboratorPayload = collabEmail.trim()
|
||||
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
|
||||
: undefined
|
||||
const collaboratorMetadata = collaboratorPayload
|
||||
? { ...collaboratorPayload, role: accessRole }
|
||||
: undefined
|
||||
const metadataPayload: Record<string, unknown> = {
|
||||
inventory: profile.inventory,
|
||||
metrics: profile.metrics,
|
||||
}
|
||||
if (collaboratorMetadata) {
|
||||
metadataPayload.collaborator = collaboratorMetadata
|
||||
}
|
||||
const payload = {
|
||||
provisioningSecret: provisioningSecret.trim(),
|
||||
tenantId: tenantId.trim() || undefined,
|
||||
|
|
@ -213,7 +244,9 @@ function App() {
|
|||
os: profile.os,
|
||||
macAddresses: profile.macAddresses,
|
||||
serialNumbers: profile.serialNumbers,
|
||||
metadata: { inventory: profile.inventory, metrics: profile.metrics, collaborator: collaboratorPayload },
|
||||
metadata: metadataPayload,
|
||||
accessRole,
|
||||
collaborator: collaboratorPayload,
|
||||
registeredBy: "desktop-agent",
|
||||
}
|
||||
const res = await fetch(`${apiBaseUrl}/api/machines/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })
|
||||
|
|
@ -231,6 +264,10 @@ function App() {
|
|||
machineEmail: data.machineEmail ?? null,
|
||||
collaboratorEmail: collaboratorPayload?.email ?? null,
|
||||
collaboratorName: collaboratorPayload?.name ?? null,
|
||||
accessRole,
|
||||
assignedUserId: data.assignedUserId ?? null,
|
||||
assignedUserEmail: data.collaborator?.email ?? collaboratorPayload?.email ?? null,
|
||||
assignedUserName: data.collaborator?.name ?? collaboratorPayload?.name ?? null,
|
||||
apiBaseUrl,
|
||||
appUrl,
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -248,16 +285,19 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
async function openSystem() {
|
||||
if (!token || !config) return
|
||||
const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}`
|
||||
const openSystem = useCallback(() => {
|
||||
if (!token) return
|
||||
const persona = (config?.accessRole ?? accessRole) === "manager" ? "manager" : "collaborator"
|
||||
const redirectTarget = persona === "manager" ? "/dashboard" : "/portal"
|
||||
const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
|
||||
window.location.href = url
|
||||
}
|
||||
}, [token, config?.accessRole, accessRole, resolvedAppUrl])
|
||||
|
||||
async function reprovision() {
|
||||
if (!store) return
|
||||
await store.delete("token"); await store.delete("config"); await store.save()
|
||||
setToken(null); setConfig(null); setStatus(null)
|
||||
autoLaunchRef.current = false
|
||||
setToken(null); setConfig(null); setStatus(null); setAccessRole("collaborator")
|
||||
const p = await invoke<MachineProfile>("collect_machine_profile")
|
||||
setProfile(p)
|
||||
}
|
||||
|
|
@ -269,9 +309,12 @@ function App() {
|
|||
const collaboratorPayload = collabEmail.trim()
|
||||
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
|
||||
: undefined
|
||||
const collaboratorInventory = collaboratorPayload
|
||||
? { ...collaboratorPayload, role: accessRole }
|
||||
: undefined
|
||||
const inventoryPayload: Record<string, unknown> = { ...profile.inventory }
|
||||
if (collaboratorPayload) {
|
||||
inventoryPayload.collaborator = collaboratorPayload
|
||||
if (collaboratorInventory) {
|
||||
inventoryPayload.collaborator = collaboratorInventory
|
||||
}
|
||||
const payload = {
|
||||
machineToken: token,
|
||||
|
|
@ -296,27 +339,61 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
async function checkForUpdates(auto = false) {
|
||||
try {
|
||||
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<void>
|
||||
}
|
||||
const update = (await check()) as UpdateResult | null
|
||||
if (update?.available) {
|
||||
setUpdateInfo({
|
||||
tone: "info",
|
||||
message: `Atualização ${update.version} disponível. Baixando e aplicando...`,
|
||||
})
|
||||
if (typeof update.downloadAndInstall === "function") {
|
||||
await update.downloadAndInstall()
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process")
|
||||
await relaunch()
|
||||
}
|
||||
} else if (!auto) {
|
||||
setUpdateInfo({ tone: "info", message: "Nenhuma atualização disponível no momento." })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Falha ao verificar atualizações", error)
|
||||
alert("Falha ao verificar atualizações.")
|
||||
if (!auto) {
|
||||
setUpdateInfo({
|
||||
tone: "error",
|
||||
message: "Falha ao verificar atualizações. Tente novamente mais tarde.",
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
if (!auto) setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) return
|
||||
if (autoUpdateRef.current) return
|
||||
autoUpdateRef.current = true
|
||||
checkForUpdates(true).catch((err: unknown) => {
|
||||
console.error("Falha ao executar atualização automática", err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
if (autoLaunchRef.current) return
|
||||
autoLaunchRef.current = true
|
||||
openSystem()
|
||||
}, [token, config?.accessRole, openSystem])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center p-6">
|
||||
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
|
|
@ -345,6 +422,20 @@ function App() {
|
|||
<label className="text-sm font-medium">Empresa (slug opcional)</label>
|
||||
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="ex.: atlas-engenharia" value={company} onChange={(e)=>setCompany(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Perfil de acesso</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||
value={accessRole}
|
||||
onChange={(e) => setAccessRole((e.target.value as "collaborator" | "manager") ?? "collaborator")}
|
||||
>
|
||||
<option value="collaborator">Colaborador (portal)</option>
|
||||
<option value="manager">Gestor (painel completo)</option>
|
||||
</select>
|
||||
<p className="text-xs text-slate-500">
|
||||
Colaboradores veem apenas seus chamados. Gestores acompanham todos os tickets da empresa.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Colaborador (e-mail)</label>
|
||||
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="colaborador@empresa.com" value={collabEmail} onChange={(e)=>setCollabEmail(e.target.value)} />
|
||||
|
|
@ -442,9 +533,25 @@ function App() {
|
|||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="label">Atualizações</label>
|
||||
<button onClick={checkForUpdates} disabled={updating} className={cn("btn btn-outline inline-flex items-center gap-2", updating && "opacity-60")}>
|
||||
<RefreshCw className="size-4" /> Verificar atualizações
|
||||
<button
|
||||
onClick={() => checkForUpdates(false)}
|
||||
disabled={updating}
|
||||
className={cn("btn btn-outline inline-flex items-center gap-2", updating && "opacity-60")}
|
||||
>
|
||||
<RefreshCw className={cn("size-4", updating && "animate-spin")} /> Verificar atualizações
|
||||
</button>
|
||||
{updateInfo ? (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-sm leading-snug",
|
||||
updateInfo.tone === "success" && "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
updateInfo.tone === "error" && "border-rose-200 bg-rose-50 text-rose-700",
|
||||
updateInfo.tone === "info" && "border-slate-200 bg-slate-50 text-slate-700"
|
||||
)}
|
||||
>
|
||||
{updateInfo.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
|
@ -452,7 +559,7 @@ function App() {
|
|||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<img src={`${appUrl}/rever-8.png`} alt="Logotipo Rever Tecnologia" width={110} height={110} className="h-[3.45rem] w-auto" />
|
||||
<img src={`${appUrl}/raven.png`} alt="Logotipo Raven" width={110} height={110} className="h-[3.45rem] w-auto" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
// @ts-nocheck
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { MutationCtx } from "./_generated/server"
|
|||
|
||||
const DEFAULT_TENANT_ID = "tenant-atlas"
|
||||
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
||||
const ALLOWED_MACHINE_PERSONAS = new Set(["collaborator", "manager"])
|
||||
|
||||
type NormalizedIdentifiers = {
|
||||
macs: string[]
|
||||
|
|
@ -327,6 +328,11 @@ export const register = mutation({
|
|||
updatedAt: now,
|
||||
status: "online",
|
||||
registeredBy: args.registeredBy ?? existing.registeredBy,
|
||||
persona: existing.persona,
|
||||
assignedUserId: existing.assignedUserId,
|
||||
assignedUserEmail: existing.assignedUserEmail,
|
||||
assignedUserName: existing.assignedUserName,
|
||||
assignedUserRole: existing.assignedUserRole,
|
||||
})
|
||||
machineId = existing._id
|
||||
} else {
|
||||
|
|
@ -347,6 +353,11 @@ export const register = mutation({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
registeredBy: args.registeredBy,
|
||||
persona: undefined,
|
||||
assignedUserId: undefined,
|
||||
assignedUserEmail: undefined,
|
||||
assignedUserName: undefined,
|
||||
assignedUserRole: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -447,6 +458,11 @@ export const upsertInventory = mutation({
|
|||
updatedAt: now,
|
||||
status: args.metrics ? "online" : existing.status ?? "unknown",
|
||||
registeredBy: args.registeredBy ?? existing.registeredBy,
|
||||
persona: existing.persona,
|
||||
assignedUserId: existing.assignedUserId,
|
||||
assignedUserEmail: existing.assignedUserEmail,
|
||||
assignedUserName: existing.assignedUserName,
|
||||
assignedUserRole: existing.assignedUserRole,
|
||||
})
|
||||
machineId = existing._id
|
||||
} else {
|
||||
|
|
@ -467,6 +483,11 @@ export const upsertInventory = mutation({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
registeredBy: args.registeredBy,
|
||||
persona: undefined,
|
||||
assignedUserId: undefined,
|
||||
assignedUserEmail: undefined,
|
||||
assignedUserName: undefined,
|
||||
assignedUserRole: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -570,6 +591,11 @@ export const resolveToken = mutation({
|
|||
architecture: machine.architecture,
|
||||
authUserId: machine.authUserId,
|
||||
authEmail: machine.authEmail,
|
||||
persona: machine.persona ?? null,
|
||||
assignedUserId: machine.assignedUserId ?? null,
|
||||
assignedUserEmail: machine.assignedUserEmail ?? null,
|
||||
assignedUserName: machine.assignedUserName ?? null,
|
||||
assignedUserRole: machine.assignedUserRole ?? null,
|
||||
status: machine.status,
|
||||
lastHeartbeatAt: machine.lastHeartbeatAt,
|
||||
metadata: machine.metadata,
|
||||
|
|
@ -646,6 +672,11 @@ export const listByTenant = query({
|
|||
serialNumbers: machine.serialNumbers,
|
||||
authUserId: machine.authUserId ?? null,
|
||||
authEmail: machine.authEmail ?? null,
|
||||
persona: machine.persona ?? null,
|
||||
assignedUserId: machine.assignedUserId ?? null,
|
||||
assignedUserEmail: machine.assignedUserEmail ?? null,
|
||||
assignedUserName: machine.assignedUserName ?? null,
|
||||
assignedUserRole: machine.assignedUserRole ?? null,
|
||||
status: derivedStatus,
|
||||
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
||||
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
|
||||
|
|
@ -669,6 +700,140 @@ export const listByTenant = query({
|
|||
},
|
||||
})
|
||||
|
||||
export const updatePersona = mutation({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
persona: v.optional(v.string()),
|
||||
assignedUserId: v.optional(v.id("users")),
|
||||
assignedUserEmail: v.optional(v.string()),
|
||||
assignedUserName: v.optional(v.string()),
|
||||
assignedUserRole: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
}
|
||||
|
||||
let nextPersona = machine.persona ?? undefined
|
||||
const personaProvided = args.persona !== undefined
|
||||
if (args.persona !== undefined) {
|
||||
const trimmed = args.persona.trim().toLowerCase()
|
||||
if (!trimmed) {
|
||||
nextPersona = undefined
|
||||
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
|
||||
throw new ConvexError("Perfil inválido para a máquina")
|
||||
} else {
|
||||
nextPersona = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
let nextAssignedUserId = machine.assignedUserId ?? undefined
|
||||
if (args.assignedUserId !== undefined) {
|
||||
nextAssignedUserId = args.assignedUserId
|
||||
}
|
||||
|
||||
let nextAssignedEmail = machine.assignedUserEmail ?? undefined
|
||||
if (args.assignedUserEmail !== undefined) {
|
||||
const trimmedEmail = args.assignedUserEmail.trim().toLowerCase()
|
||||
nextAssignedEmail = trimmedEmail || undefined
|
||||
}
|
||||
|
||||
let nextAssignedName = machine.assignedUserName ?? undefined
|
||||
if (args.assignedUserName !== undefined) {
|
||||
const trimmedName = args.assignedUserName.trim()
|
||||
nextAssignedName = trimmedName || undefined
|
||||
}
|
||||
|
||||
let nextAssignedRole = machine.assignedUserRole ?? undefined
|
||||
if (args.assignedUserRole !== undefined) {
|
||||
const trimmedRole = args.assignedUserRole.trim().toUpperCase()
|
||||
nextAssignedRole = trimmedRole || undefined
|
||||
}
|
||||
|
||||
if (nextPersona && !nextAssignedUserId) {
|
||||
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
|
||||
}
|
||||
|
||||
if (nextAssignedUserId) {
|
||||
const assignedUser = await ctx.db.get(nextAssignedUserId)
|
||||
if (!assignedUser) {
|
||||
throw new ConvexError("Usuário vinculado não encontrado")
|
||||
}
|
||||
if (assignedUser.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Usuário vinculado pertence a outro tenant")
|
||||
}
|
||||
}
|
||||
|
||||
let nextMetadata = machine.metadata
|
||||
if (nextPersona) {
|
||||
const collaboratorMeta = {
|
||||
email: nextAssignedEmail ?? null,
|
||||
name: nextAssignedName ?? null,
|
||||
role: nextPersona,
|
||||
}
|
||||
nextMetadata = mergeMetadata(machine.metadata, { collaborator: collaboratorMeta })
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
persona: nextPersona,
|
||||
assignedUserId: nextPersona ? nextAssignedUserId : undefined,
|
||||
assignedUserEmail: nextPersona ? nextAssignedEmail : undefined,
|
||||
assignedUserName: nextPersona ? nextAssignedName : undefined,
|
||||
assignedUserRole: nextPersona ? nextAssignedRole : undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
if (nextMetadata !== machine.metadata) {
|
||||
patch.metadata = nextMetadata
|
||||
}
|
||||
|
||||
if (personaProvided) {
|
||||
patch.persona = nextPersona
|
||||
}
|
||||
|
||||
if (nextPersona) {
|
||||
patch.assignedUserId = nextAssignedUserId
|
||||
patch.assignedUserEmail = nextAssignedEmail
|
||||
patch.assignedUserName = nextAssignedName
|
||||
patch.assignedUserRole = nextAssignedRole
|
||||
} else if (personaProvided) {
|
||||
patch.assignedUserId = undefined
|
||||
patch.assignedUserEmail = undefined
|
||||
patch.assignedUserName = undefined
|
||||
patch.assignedUserRole = undefined
|
||||
}
|
||||
|
||||
await ctx.db.patch(machine._id, patch)
|
||||
return { ok: true, persona: nextPersona ?? null }
|
||||
},
|
||||
})
|
||||
|
||||
export const getContext = query({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
}
|
||||
|
||||
return {
|
||||
id: machine._id,
|
||||
tenantId: machine.tenantId,
|
||||
companyId: machine.companyId ?? null,
|
||||
companySlug: machine.companySlug ?? null,
|
||||
persona: machine.persona ?? null,
|
||||
assignedUserId: machine.assignedUserId ?? null,
|
||||
assignedUserEmail: machine.assignedUserEmail ?? null,
|
||||
assignedUserName: machine.assignedUserName ?? null,
|
||||
assignedUserRole: machine.assignedUserRole ?? null,
|
||||
metadata: machine.metadata ?? null,
|
||||
authEmail: machine.authEmail ?? null,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const linkAuthAccount = mutation({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
|
|
@ -710,7 +875,7 @@ export const rename = mutation({
|
|||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
}
|
||||
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(normalizedRole)) {
|
||||
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
|
||||
}
|
||||
|
|
@ -741,7 +906,7 @@ export const remove = mutation({
|
|||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
}
|
||||
const role = (actor.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(role)) {
|
||||
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { Id, type Doc } from "./_generated/dataModel";
|
|||
|
||||
import { requireStaff, requireUser } from "./rbac";
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
|
||||
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
|
||||
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
|
||||
const PAUSE_REASON_LABELS: Record<string, string> = {
|
||||
NO_CONTACT: "Falta de contato",
|
||||
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
13
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -10,15 +10,17 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
|
|||
- Marcação “Somente com alertas” para investigar postura.
|
||||
|
||||
## Painel de detalhes
|
||||
- Resumo: hostname, status, e-mail vinculado, SO/arch, sincronização do token (expiração/uso).
|
||||
- Resumo: hostname, status, e-mail vinculado, empresa (quando houver), perfil de acesso (colaborador/gestor) com dados do usuário associado, SO/arch e sincronização do token (expiração/uso).
|
||||
- Métricas recentes: CPU/Memory/Disco.
|
||||
- Inventário básico: hardware (CPU/mem/serial), rede (IP/MAC), labels.
|
||||
- Inventário básico: hardware (CPU/mem/serial, GPUs detectadas), rede (IP/MAC), labels.
|
||||
- Discos e partições: nome, mount, FS, capacidade, livre.
|
||||
- Inventário estendido (varia por SO):
|
||||
- Linux: SMART (OK/ALERTA), `lspci`, `lsusb` (texto), `lsblk` (interno para discos).
|
||||
- Windows: serviços (amostra), softwares instalados (amostra), Defender.
|
||||
- Windows: serviços (amostra), softwares instalados (amostra), Defender, resumo de hardware (CPU/Memória/GPU/Discos físicos).
|
||||
- macOS: pacotes (`pkgutil`), serviços (`launchctl`).
|
||||
- Postura/Alertas: CPU alta, serviço parado, SMART em falha com severidade e última avaliação.
|
||||
- Zona perigosa: ação para excluir a máquina (revoga tokens e remove inventário).
|
||||
- Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a máquina.
|
||||
|
||||
## Exportação
|
||||
- Copiar JSON: copia para a área de transferência todo o inventário exibido (métricas + inventário + alertas).
|
||||
|
|
|
|||
111
docs/desktop-updater.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Checklist de Publicação — Updater do Agente Desktop
|
||||
|
||||
Este guia consolida tudo o que precisa ser feito para que o auto-update do Tauri funcione em cada release.
|
||||
|
||||
---
|
||||
|
||||
## 1. Preparação (uma única vez)
|
||||
|
||||
1. **Gerar o par de chaves**
|
||||
Execute na raiz do repositório (aproveite que o comando já foi rodado):
|
||||
```bash
|
||||
pnpm -C apps/desktop tauri signer generate -w ~/.tauri/raven.key
|
||||
```
|
||||
- Privada: `~/.tauri/raven.key` (nunca compartilhar)
|
||||
- Pública: `~/.tauri/raven.key.pub` (cole em `tauri.conf.json > plugins.updater.pubkey`)
|
||||
|
||||
2. **Verificar o `tauri.conf.json`**
|
||||
```json
|
||||
{
|
||||
"bundle": { "createUpdaterArtifacts": true },
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": ["https://.../latest.json"],
|
||||
"pubkey": "<conteúdo da raven.key.pub>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Antes de cada release
|
||||
|
||||
1. **Sincronizar versão** (mesmo número nos três arquivos):
|
||||
- `apps/desktop/package.json`
|
||||
- `apps/desktop/src-tauri/tauri.conf.json`
|
||||
- `apps/desktop/src-tauri/Cargo.toml`
|
||||
|
||||
2. **Build do front (gera `dist/` para o Tauri)**
|
||||
```bash
|
||||
pnpm -C apps/desktop build
|
||||
```
|
||||
|
||||
3. **Exportar variáveis do assinador** (no mesmo shell em que vai buildar):
|
||||
```bash
|
||||
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/raven.key)"
|
||||
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="<senha-da-chave>"
|
||||
```
|
||||
> No PowerShell, use `setx` para persistir ou execute `set`/`$env:` no terminal atual.
|
||||
|
||||
4. **Gerar os instaladores + `.sig`**
|
||||
```bash
|
||||
pnpm -C apps/desktop tauri build
|
||||
```
|
||||
Os artefatos ficam em `apps/desktop/src-tauri/target/release/bundle/`:
|
||||
|
||||
| SO | Bundle principal | Assinatura gerada |
|
||||
|----------|----------------------------------------------|-----------------------------------------|
|
||||
| Windows | `nsis/Raven_0.X.Y_x64-setup.exe` | `nsis/Raven_0.X.Y_x64-setup.exe.sig` |
|
||||
| Linux | `appimage/Raven_0.X.Y_amd64.AppImage` | `appimage/Raven_0.X.Y_amd64.AppImage.sig` |
|
||||
| macOS | `macos/Raven.app.tar.gz` | `macos/Raven.app.tar.gz.sig` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Publicar no GitHub
|
||||
|
||||
1. **Criar/atualizar release** (ex.: `v0.1.6`) anexando todos os instaladores e seus `.sig`.
|
||||
2. **Atualizar `latest.json`** (no próprio repo ou em um gist público) com algo como:
|
||||
```json
|
||||
{
|
||||
"version": "0.1.6",
|
||||
"notes": "Novidades do release",
|
||||
"pub_date": "2025-10-12T08:00:00Z",
|
||||
"platforms": {
|
||||
"windows-x86_64": {
|
||||
"signature": "<conteúdo de Raven_0.1.6_x64-setup.exe.sig>",
|
||||
"url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven_0.1.6_x64-setup.exe"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "<conteúdo de Raven_0.1.6_amd64.AppImage.sig>",
|
||||
"url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven_0.1.6_amd64.AppImage"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "<conteúdo de Raven.app.tar.gz.sig>",
|
||||
"url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven.app.tar.gz"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Pegue o link **Raw** do `latest.json` e mantenha igual ao usado no `tauri.conf.json`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Validar rapidamente
|
||||
|
||||
1. Instale a versão anterior (ex.: 0.1.5) e abra.
|
||||
2. O agente deve avisar sobre a nova versão e reiniciar automaticamente ao concluir a instalação.
|
||||
3. Caso queira forçar manualmente, abra a aba **Configurações → Verificar atualizações**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Resumo rápido
|
||||
|
||||
1. `pnpm -C apps/desktop build`
|
||||
2. `export TAURI_SIGNING_PRIVATE_KEY=...` / `export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=...`
|
||||
3. `pnpm -C apps/desktop tauri build`
|
||||
4. Upload dos bundles + `.sig` → atualizar `latest.json`
|
||||
5. Testar o instalador antigo para garantir que atualiza sozinho
|
||||
|
||||
Com isso, os usuários sempre receberão a versão mais recente assim que abrirem o agente desktop.
|
||||
|
|
@ -17,6 +17,8 @@ const eslintConfig = [
|
|||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"apps/desktop/dist/**",
|
||||
"apps/desktop/src-tauri/target/**",
|
||||
"next-env.d.ts",
|
||||
"convex/_generated/**",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "AuthUser" ADD COLUMN "machinePersona" TEXT;
|
||||
|
|
@ -217,6 +217,7 @@ model AuthUser {
|
|||
role String @default("agent")
|
||||
tenantId String?
|
||||
avatarUrl String?
|
||||
machinePersona String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions AuthSession[]
|
||||
|
|
|
|||
BIN
public/raven.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
78
src/app/api/admin/machines/access/route.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { assertAdminSession } from "@/lib/auth-server"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
const schema = z.object({
|
||||
machineId: z.string().min(1),
|
||||
persona: z.enum(["collaborator", "manager"]),
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
let parsed: z.infer<typeof schema>
|
||||
try {
|
||||
const body = await request.json()
|
||||
parsed = schema.parse(body)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const machine = (await client.query(api.machines.getContext, {
|
||||
machineId: parsed.machineId as Id<"machines">,
|
||||
})) as {
|
||||
id: string
|
||||
tenantId: string
|
||||
companyId: string | null
|
||||
} | null
|
||||
|
||||
if (!machine) {
|
||||
return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 })
|
||||
}
|
||||
|
||||
const tenantId = machine.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
const ensuredUser = (await client.mutation(api.users.ensureUser, {
|
||||
tenantId,
|
||||
email: parsed.email,
|
||||
name: parsed.name ?? parsed.email,
|
||||
avatarUrl: undefined,
|
||||
role: parsed.persona.toUpperCase(),
|
||||
companyId: machine.companyId ? (machine.companyId as Id<"companies">) : undefined,
|
||||
})) as { _id?: Id<"users"> } | null
|
||||
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
machineId: parsed.machineId as Id<"machines">,
|
||||
persona: parsed.persona,
|
||||
assignedUserId: ensuredUser?._id,
|
||||
assignedUserEmail: parsed.email,
|
||||
assignedUserName: parsed.name ?? undefined,
|
||||
assignedUserRole: parsed.persona === "manager" ? "MANAGER" : "COLLABORATOR",
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (error) {
|
||||
console.error("[machines.access]", error)
|
||||
return NextResponse.json({ error: "Falha ao atualizar acesso da máquina" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,13 @@ const registerSchema = z
|
|||
serialNumbers: z.array(z.string()).default([]),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
registeredBy: z.string().optional(),
|
||||
accessRole: z.enum(["collaborator", "manager"]).optional(),
|
||||
collaborator: z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => (data.macAddresses && data.macAddresses.length > 0) || (data.serialNumbers && data.serialNumbers.length > 0),
|
||||
|
|
@ -61,15 +68,43 @@ export async function POST(request: Request) {
|
|||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const tenantId = payload.tenantId ?? DEFAULT_TENANT_ID
|
||||
const persona = payload.accessRole ?? undefined
|
||||
const collaborator = payload.collaborator ?? null
|
||||
|
||||
if (persona && !collaborator) {
|
||||
return jsonWithCors(
|
||||
{ error: "Informe os dados do colaborador/gestor ao definir o perfil de acesso." },
|
||||
400,
|
||||
request.headers.get("origin"),
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
let metadataPayload: Record<string, unknown> | undefined = payload.metadata
|
||||
? { ...(payload.metadata as Record<string, unknown>) }
|
||||
: undefined
|
||||
if (collaborator) {
|
||||
const collaboratorMeta = {
|
||||
email: collaborator.email,
|
||||
name: collaborator.name ?? null,
|
||||
role: persona ?? "collaborator",
|
||||
}
|
||||
metadataPayload = {
|
||||
...(metadataPayload ?? {}),
|
||||
collaborator: collaboratorMeta,
|
||||
}
|
||||
}
|
||||
|
||||
const registration = await client.mutation(api.machines.register, {
|
||||
provisioningSecret: payload.provisioningSecret,
|
||||
tenantId: payload.tenantId ?? DEFAULT_TENANT_ID,
|
||||
tenantId,
|
||||
companySlug: payload.companySlug ?? undefined,
|
||||
hostname: payload.hostname,
|
||||
os: payload.os,
|
||||
macAddresses: payload.macAddresses,
|
||||
serialNumbers: payload.serialNumbers,
|
||||
metadata: payload.metadata,
|
||||
metadata: metadataPayload,
|
||||
registeredBy: payload.registeredBy,
|
||||
})
|
||||
|
||||
|
|
@ -78,6 +113,7 @@ export async function POST(request: Request) {
|
|||
tenantId: registration.tenantId ?? DEFAULT_TENANT_ID,
|
||||
hostname: payload.hostname,
|
||||
machineToken: registration.machineToken,
|
||||
persona,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
|
|
@ -86,6 +122,34 @@ export async function POST(request: Request) {
|
|||
authEmail: account.authEmail,
|
||||
})
|
||||
|
||||
let assignedUserId: Id<"users"> | undefined
|
||||
if (persona && collaborator) {
|
||||
const ensuredUser = (await client.mutation(api.users.ensureUser, {
|
||||
tenantId,
|
||||
email: collaborator.email,
|
||||
name: collaborator.name ?? collaborator.email,
|
||||
avatarUrl: undefined,
|
||||
role: persona.toUpperCase(),
|
||||
companyId: registration.companyId ? (registration.companyId as Id<"companies">) : undefined,
|
||||
})) as { _id?: Id<"users"> } | null
|
||||
|
||||
assignedUserId = ensuredUser?._id
|
||||
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
persona,
|
||||
...(assignedUserId ? { assignedUserId } : {}),
|
||||
assignedUserEmail: collaborator.email,
|
||||
assignedUserName: collaborator.name ?? undefined,
|
||||
assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR",
|
||||
})
|
||||
} else {
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
persona: "",
|
||||
})
|
||||
}
|
||||
|
||||
return jsonWithCors(
|
||||
{
|
||||
machineId: registration.machineId,
|
||||
|
|
@ -95,6 +159,9 @@ export async function POST(request: Request) {
|
|||
machineToken: registration.machineToken,
|
||||
machineEmail: account.authEmail,
|
||||
expiresAt: registration.expiresAt,
|
||||
persona: persona ?? null,
|
||||
assignedUserId: assignedUserId ?? null,
|
||||
collaborator: collaborator ?? null,
|
||||
},
|
||||
{ status: 201 },
|
||||
request.headers.get("origin"),
|
||||
|
|
|
|||
77
src/app/api/machines/session/route.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||
|
||||
const MACHINE_CTX_COOKIE = "machine_ctx"
|
||||
|
||||
function decodeMachineCookie(value: string) {
|
||||
try {
|
||||
const json = Buffer.from(value, "base64url").toString("utf8")
|
||||
return JSON.parse(json) as {
|
||||
machineId: string
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await assertAuthenticatedSession()
|
||||
if (!session || session.user?.role !== "machine") {
|
||||
return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 })
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value
|
||||
if (!cookieValue) {
|
||||
return NextResponse.json({ error: "Contexto da máquina ausente." }, { status: 404 })
|
||||
}
|
||||
|
||||
const decoded = decodeMachineCookie(cookieValue)
|
||||
if (!decoded?.machineId) {
|
||||
return NextResponse.json({ error: "Contexto da máquina inválido." }, { status: 400 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado." }, { status: 500 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const context = (await client.query(api.machines.getContext, {
|
||||
machineId: decoded.machineId as Id<"machines">,
|
||||
})) as {
|
||||
id: string
|
||||
tenantId: string
|
||||
companyId: string | null
|
||||
companySlug: string | null
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
authEmail: string | null
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
machine: context,
|
||||
cookie: decoded,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[machines.session] Falha ao obter contexto da máquina", error)
|
||||
return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const metadata: Metadata = {
|
|||
title: "Raven",
|
||||
description: "Plataforma Raven da Rever",
|
||||
icons: {
|
||||
icon: "/rever-8.png",
|
||||
icon: "/raven.png",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function LoginPageClient() {
|
|||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/rever-8.png"
|
||||
src="/raven.png"
|
||||
alt="Logotipo Rever Tecnologia"
|
||||
width={110}
|
||||
height={110}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,25 @@ export async function GET(request: NextRequest) {
|
|||
}
|
||||
})
|
||||
|
||||
const machineCookiePayload = {
|
||||
machineId: session.machine.id,
|
||||
persona: session.machine.persona,
|
||||
assignedUserId: session.machine.assignedUserId,
|
||||
assignedUserEmail: session.machine.assignedUserEmail,
|
||||
assignedUserName: session.machine.assignedUserName,
|
||||
assignedUserRole: session.machine.assignedUserRole,
|
||||
}
|
||||
const encodedContext = Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url")
|
||||
response.cookies.set({
|
||||
name: "machine_ctx",
|
||||
value: encodedContext,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[machines.handshake] Falha ao autenticar máquina", error)
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ type MachineInventory = {
|
|||
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; interface?: string | null; serial?: string | null; totalBytes?: number; availableBytes?: number }>
|
||||
extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
|
||||
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
||||
collaborator?: { email?: string; name?: string }
|
||||
collaborator?: { email?: string; name?: string; role?: string }
|
||||
}
|
||||
|
||||
export type MachinesQueryItem = {
|
||||
|
|
@ -135,6 +135,11 @@ export type MachinesQueryItem = {
|
|||
serialNumbers: string[]
|
||||
authUserId: string | null
|
||||
authEmail: string | null
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
status: string | null
|
||||
lastHeartbeatAt: number | null
|
||||
heartbeatAgeMs: number | null
|
||||
|
|
@ -209,12 +214,6 @@ function formatPercent(value?: number | null) {
|
|||
return `${normalized.toFixed(0)}%`
|
||||
}
|
||||
|
||||
function fmtBool(value: unknown) {
|
||||
if (value === true) return "Sim"
|
||||
if (value === false) return "Não"
|
||||
return "—"
|
||||
}
|
||||
|
||||
function readBool(source: unknown, key: string): boolean | undefined {
|
||||
if (!source || typeof source !== "object") return undefined
|
||||
const value = (source as Record<string, unknown>)[key]
|
||||
|
|
@ -490,15 +489,31 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// collaborator (from inventory metadata, when provided by onboarding)
|
||||
type Collaborator = { email?: string; name?: string }
|
||||
const collaborator: Collaborator | null = (() => {
|
||||
// collaborator (from machine assignment or metadata)
|
||||
type Collaborator = { email?: string; name?: string; role?: string }
|
||||
const collaborator: Collaborator | null = useMemo(() => {
|
||||
if (machine?.assignedUserEmail) {
|
||||
return {
|
||||
email: machine.assignedUserEmail ?? undefined,
|
||||
name: machine.assignedUserName ?? undefined,
|
||||
role: machine.persona ?? machine.assignedUserRole ?? undefined,
|
||||
}
|
||||
}
|
||||
if (!metadata || typeof metadata !== "object") return null
|
||||
const inv = metadata as Record<string, unknown>
|
||||
const c = inv["collaborator"]
|
||||
if (c && typeof c === "object") return c as Collaborator
|
||||
if (c && typeof c === "object") {
|
||||
const base = c as Record<string, unknown>
|
||||
return {
|
||||
email: typeof base.email === "string" ? base.email : undefined,
|
||||
name: typeof base.name === "string" ? base.name : undefined,
|
||||
role: typeof base.role === "string" ? (base.role as string) : undefined,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})()
|
||||
}, [machine?.assignedUserEmail, machine?.assignedUserName, machine?.persona, machine?.assignedUserRole, metadata])
|
||||
|
||||
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const companyName = (() => {
|
||||
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
||||
|
|
@ -513,6 +528,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
const [dialogQuery, setDialogQuery] = useState("")
|
||||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [accessDialog, setAccessDialog] = useState(false)
|
||||
const [accessEmail, setAccessEmail] = useState<string>(collaborator?.email ?? "")
|
||||
const [accessName, setAccessName] = useState<string>(collaborator?.name ?? "")
|
||||
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(
|
||||
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
|
||||
)
|
||||
const [savingAccess, setSavingAccess] = useState(false)
|
||||
const jsonText = useMemo(() => {
|
||||
const payload = {
|
||||
id: machine?.id,
|
||||
|
|
@ -535,6 +557,42 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}, [jsonText, dialogQuery])
|
||||
|
||||
// removed copy/export inventory JSON buttons as requested
|
||||
useEffect(() => {
|
||||
setAccessEmail(collaborator?.email ?? "")
|
||||
setAccessName(collaborator?.name ?? "")
|
||||
setAccessRole((machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator")
|
||||
}, [machine?.id, machine?.persona, collaborator?.email, collaborator?.name, collaborator?.role])
|
||||
|
||||
const handleSaveAccess = async () => {
|
||||
if (!machine) return
|
||||
if (!accessEmail.trim()) {
|
||||
toast.error("Informe o e-mail do colaborador ou gestor.")
|
||||
return
|
||||
}
|
||||
setSavingAccess(true)
|
||||
try {
|
||||
const response = await fetch("/api/admin/machines/access", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
machineId: machine.id,
|
||||
persona: accessRole,
|
||||
email: accessEmail.trim(),
|
||||
name: accessName.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text())
|
||||
}
|
||||
toast.success("Perfil de acesso atualizado.")
|
||||
setAccessDialog(false)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Falha ao atualizar acesso da máquina.")
|
||||
} finally {
|
||||
setSavingAccess(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
|
|
@ -594,17 +652,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
) : null}
|
||||
{collaborator?.email ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
Colaborador: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
|
||||
{personaLabel}: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{machine.authEmail ? (
|
||||
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
|
||||
<ClipboardCopy className="size-4" />
|
||||
Copiar e-mail
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{machine.authEmail ? (
|
||||
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
|
||||
<ClipboardCopy className="size-4" />
|
||||
Copiar e-mail
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
|
||||
<ShieldCheck className="size-4" />
|
||||
Ajustar acesso
|
||||
</Button>
|
||||
{machine.registeredBy ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
Registrada via {machine.registeredBy}
|
||||
|
|
@ -653,6 +715,42 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={accessDialog} onOpenChange={setAccessDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajustar acesso da máquina</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Perfil</label>
|
||||
<Select value={accessRole} onValueChange={(value) => setAccessRole((value as "collaborator" | "manager") ?? "collaborator")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o perfil" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="collaborator">Colaborador (portal)</SelectItem>
|
||||
<SelectItem value="manager">Gestor (painel completo)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">E-mail</label>
|
||||
<Input type="email" value={accessEmail} onChange={(e) => setAccessEmail(e.target.value)} placeholder="colaborador@empresa.com" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Nome (opcional)</label>
|
||||
<Input value={accessName} onChange={(e) => setAccessName(e.target.value)} placeholder="Nome completo" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setAccessDialog(false)} disabled={savingAccess}>Cancelar</Button>
|
||||
<Button onClick={handleSaveAccess} disabled={savingAccess || !accessEmail.trim()}>
|
||||
{savingAccess ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Sincronização</h4>
|
||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||
|
|
@ -1372,16 +1470,27 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
|||
const memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN)
|
||||
const cpuPct = mm?.cpuUsagePercent ?? NaN
|
||||
const collaborator = (() => {
|
||||
if (machine.assignedUserEmail) {
|
||||
return {
|
||||
email: machine.assignedUserEmail ?? undefined,
|
||||
name: machine.assignedUserName ?? undefined,
|
||||
role: machine.persona ?? machine.assignedUserRole ?? undefined,
|
||||
}
|
||||
}
|
||||
const inv = machine.inventory as unknown
|
||||
if (!inv || typeof inv !== "object") return null
|
||||
const raw = (inv as Record<string, unknown>).collaborator
|
||||
if (!raw || typeof raw !== "object") return null
|
||||
const obj = raw as Record<string, unknown>
|
||||
const email = typeof obj.email === "string" ? obj.email : undefined
|
||||
const name = typeof obj.name === "string" ? obj.name : undefined
|
||||
if (!email) return null
|
||||
return { email, name }
|
||||
return {
|
||||
email,
|
||||
name: typeof obj.name === "string" ? obj.name : undefined,
|
||||
role: typeof obj.role === "string" ? (obj.role as string) : undefined,
|
||||
}
|
||||
})()
|
||||
const persona = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
|
||||
const companyLabel = companyName ?? machine.companySlug ?? null
|
||||
|
||||
return (
|
||||
|
|
@ -1427,7 +1536,7 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
|||
</div>
|
||||
{collaborator?.email ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{collaborator.name ? `${collaborator.name} · ` : ""}
|
||||
{persona}: {collaborator.name ? `${collaborator.name} · ` : ""}
|
||||
{collaborator.email}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -23,17 +23,21 @@ const navItems = [
|
|||
export function PortalShell({ children }: PortalShellProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { session } = useAuth()
|
||||
const { session, machineContext } = useAuth()
|
||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||
|
||||
const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente"
|
||||
const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? ""
|
||||
const personaLabel = machineContext?.persona === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const name = session?.user.name || session?.user.email || "Cliente"
|
||||
const name = displayName || displayEmail || "Cliente"
|
||||
return name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join("")
|
||||
}, [session?.user.name, session?.user.email])
|
||||
}, [displayName, displayEmail])
|
||||
|
||||
async function handleSignOut() {
|
||||
if (isSigningOut) return
|
||||
|
|
@ -85,12 +89,15 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Avatar className="size-9 border border-slate-200">
|
||||
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={session?.user.name ?? ""} />
|
||||
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={displayName ?? ""} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-neutral-900">{session?.user.name ?? "Cliente"}</span>
|
||||
<span className="text-xs text-neutral-500">{session?.user.email ?? ""}</span>
|
||||
<span className="font-semibold text-neutral-900">{displayName}</span>
|
||||
<span className="text-xs text-neutral-500">{displayEmail}</span>
|
||||
{machineContext ? (
|
||||
<span className="text-[10px] uppercase tracking-wide text-neutral-400">{personaLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export type AppSession = {
|
|||
role: string
|
||||
tenantId: string | null
|
||||
avatarUrl: string | null
|
||||
machinePersona?: string | null
|
||||
}
|
||||
session: {
|
||||
id: string
|
||||
|
|
@ -25,6 +26,17 @@ export type AppSession = {
|
|||
}
|
||||
}
|
||||
|
||||
type MachineContext = {
|
||||
machineId: string
|
||||
tenantId: string
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
companyId: string | null
|
||||
}
|
||||
|
||||
const authClient = createAuthClient({
|
||||
plugins: [customSessionClient<AppAuth>()],
|
||||
fetchOptions: {
|
||||
|
|
@ -40,6 +52,7 @@ type AuthContextValue = {
|
|||
isAdmin: boolean
|
||||
isStaff: boolean
|
||||
isCustomer: boolean
|
||||
machineContext: MachineContext | null
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
|
|
@ -50,6 +63,7 @@ const AuthContext = createContext<AuthContextValue>({
|
|||
isAdmin: false,
|
||||
isStaff: false,
|
||||
isCustomer: false,
|
||||
machineContext: null,
|
||||
})
|
||||
|
||||
export function useAuth() {
|
||||
|
|
@ -62,15 +76,68 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
const { data: session, isPending } = useSession()
|
||||
const ensureUser = useMutation(api.users.ensureUser)
|
||||
const [convexUserId, setConvexUserId] = useState<string | null>(null)
|
||||
const [machineContext, setMachineContext] = useState<MachineContext | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user) {
|
||||
if (!session?.user || session.user.role === "machine") {
|
||||
setConvexUserId(null)
|
||||
}
|
||||
}, [session?.user])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user || convexUserId) return
|
||||
if (!session?.user || session.user.role !== "machine") {
|
||||
setMachineContext(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/machines/session", { credentials: "include" })
|
||||
if (!response.ok) {
|
||||
if (!cancelled) {
|
||||
setMachineContext(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!cancelled) {
|
||||
const machine = data.machine as {
|
||||
id: string
|
||||
tenantId: string
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
companyId: string | null
|
||||
}
|
||||
setMachineContext({
|
||||
machineId: machine.id,
|
||||
tenantId: machine.tenantId,
|
||||
persona: machine.persona ?? null,
|
||||
assignedUserId: machine.assignedUserId ?? null,
|
||||
assignedUserEmail: machine.assignedUserEmail ?? null,
|
||||
assignedUserName: machine.assignedUserName ?? null,
|
||||
assignedUserRole: machine.assignedUserRole ?? null,
|
||||
companyId: machine.companyId ?? null,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load machine context", error)
|
||||
if (!cancelled) {
|
||||
setMachineContext(null)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [session?.user])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user || session.user.role === "machine" || convexUserId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
|
|
@ -99,19 +166,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ensureUser, session?.user?.email, session?.user?.tenantId, session?.user?.role, convexUserId])
|
||||
|
||||
const normalizedRole = session?.user?.role ? session.user.role.toLowerCase() : null
|
||||
const baseRole = session?.user?.role ? session.user.role.toLowerCase() : null
|
||||
const personaRole = session?.user?.machinePersona ? session.user.machinePersona.toLowerCase() : null
|
||||
const normalizedRole =
|
||||
baseRole === "machine" ? machineContext?.persona ?? personaRole ?? null : baseRole
|
||||
|
||||
const effectiveConvexUserId =
|
||||
baseRole === "machine" ? machineContext?.assignedUserId ?? null : convexUserId
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session: session ?? null,
|
||||
isLoading: isPending,
|
||||
convexUserId,
|
||||
convexUserId: effectiveConvexUserId,
|
||||
role: normalizedRole,
|
||||
isAdmin: isAdmin(normalizedRole),
|
||||
isStaff: isStaff(normalizedRole),
|
||||
isCustomer: false,
|
||||
isCustomer: normalizedRole === "collaborator",
|
||||
machineContext,
|
||||
}),
|
||||
[session, isPending, convexUserId, normalizedRole]
|
||||
[session, isPending, effectiveConvexUserId, normalizedRole, machineContext]
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ export const auth = betterAuth({
|
|||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
machinePersona: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
|
|
@ -76,6 +81,7 @@ export const auth = betterAuth({
|
|||
role: ((user as { role?: string }).role ?? "agent").toLowerCase(),
|
||||
tenantId: (user as { tenantId?: string | null }).tenantId ?? null,
|
||||
avatarUrl: (user as { avatarUrl?: string | null }).avatarUrl ?? null,
|
||||
machinePersona: (user as { machinePersona?: string | null }).machinePersona ?? null,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator"] as const
|
||||
|
||||
const ADMIN_ROLE = "admin"
|
||||
const STAFF_ROLES = new Set(["admin", "manager", "agent", "collaborator"])
|
||||
const PORTAL_ROLE = "collaborator"
|
||||
const STAFF_ROLES = new Set(["admin", "manager", "agent"])
|
||||
|
||||
export type RoleOption = (typeof ROLE_OPTIONS)[number]
|
||||
|
||||
|
|
@ -16,3 +17,7 @@ export function isAdmin(role?: string | null) {
|
|||
export function isStaff(role?: string | null) {
|
||||
return STAFF_ROLES.has(normalizeRole(role) ?? "")
|
||||
}
|
||||
|
||||
export function isPortalUser(role?: string | null) {
|
||||
return normalizeRole(role) === PORTAL_ROLE
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ type EnsureMachineAccountParams = {
|
|||
tenantId: string
|
||||
hostname: string
|
||||
machineToken: string
|
||||
persona?: string
|
||||
}
|
||||
|
||||
export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
|
||||
const { machineId, tenantId, hostname, machineToken } = params
|
||||
const { machineId, tenantId, hostname, machineToken, persona } = params
|
||||
const machineEmail = `machine-${machineId}@machines.local`
|
||||
const context = await auth.$context
|
||||
|
||||
|
|
@ -22,12 +23,14 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
|
|||
name: machineName,
|
||||
tenantId,
|
||||
role: "machine",
|
||||
machinePersona: persona ?? null,
|
||||
},
|
||||
create: {
|
||||
email: machineEmail,
|
||||
name: machineName,
|
||||
role: "machine",
|
||||
tenantId,
|
||||
machinePersona: persona ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export type MachineSessionContext = {
|
|||
companyId: Id<"companies"> | null
|
||||
companySlug: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
persona: string | null
|
||||
assignedUserId: Id<"users"> | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
}
|
||||
headers: Headers
|
||||
response: unknown
|
||||
|
|
@ -41,6 +46,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
|
|||
tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID,
|
||||
hostname: resolved.machine.hostname,
|
||||
machineToken,
|
||||
persona: (resolved.machine.persona ?? null) ?? undefined,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
|
|
@ -73,6 +79,11 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
|
|||
companyId: (resolved.machine.companyId ?? null) as Id<"companies"> | null,
|
||||
companySlug: resolved.machine.companySlug ?? null,
|
||||
metadata: (resolved.machine.metadata ?? null) as Record<string, unknown> | null,
|
||||
persona: (resolved.machine.persona ?? null) as string | null,
|
||||
assignedUserId: (resolved.machine.assignedUserId ?? null) as Id<"users"> | null,
|
||||
assignedUserEmail: resolved.machine.assignedUserEmail ?? null,
|
||||
assignedUserName: resolved.machine.assignedUserName ?? null,
|
||||
assignedUserRole: resolved.machine.assignedUserRole ?? null,
|
||||
},
|
||||
headers: signIn.headers,
|
||||
response: signIn.response,
|
||||
|
|
|
|||