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 build` — build do frontend (dist).
|
||||||
- `pnpm -C apps/desktop tauri build` — gera instaladores (bundle) por SO.
|
- `pnpm -C apps/desktop tauri build` — gera instaladores (bundle) por SO.
|
||||||
- Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/`.
|
- Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/`.
|
||||||
- Fluxo:
|
- Fluxo atualizado:
|
||||||
1) Coleta perfil (hostname/OS/MAC/seriais/métricas).
|
1) Coleta perfil (hostname/OS/MAC/seriais/métricas).
|
||||||
2) Provisiona via `POST /api/machines/register` com `MACHINE_PROVISIONING_SECRET`.
|
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.
|
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=...` para autenticar sessão na UI.
|
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.
|
- 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).
|
- 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)
|
## 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`.
|
- 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 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/**`.
|
- 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.
|
- 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.
|
- Banco Prisma (SQLite) persiste em volume nomeado (`sistema_db`); não é recriado a cada deploy.
|
||||||
|
|
||||||
## Bancos e seeds — DEV x PROD
|
## Bancos e seeds — DEV x PROD
|
||||||
|
|
@ -151,6 +154,7 @@ Observações:
|
||||||
|
|
||||||
### Papéis
|
### Papéis
|
||||||
- Papéis válidos: `admin`, `manager`, `agent`, `collaborator` (papel `customer` removido).
|
- 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.
|
- Gestores veem os tickets da própria empresa e só podem registrar comentários públicos.
|
||||||
|
|
||||||
## Próximos passos sugeridos
|
## Próximos passos sugeridos
|
||||||
|
|
@ -167,7 +171,7 @@ Observações:
|
||||||
- Horas por cliente CSV: `/api/reports/hours-by-client.csv?range=7d|30d|90d`
|
- Horas por cliente CSV: `/api/reports/hours-by-client.csv?range=7d|30d|90d`
|
||||||
|
|
||||||
## Referências de inventário de máquinas
|
## 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:
|
- Endpoints do agente:
|
||||||
- `POST /api/machines/register`
|
- `POST /api/machines/register`
|
||||||
- `POST /api/machines/heartbeat`
|
- `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",
|
"title": "Raven",
|
||||||
"width": 1100,
|
"width": 1100,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"resizable": true
|
"resizable": true,
|
||||||
|
"fullscreen": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|
@ -29,11 +30,13 @@
|
||||||
"https://github.com/esdrasrenan/sistema-de-chamados/releases/latest/download/latest.json"
|
"https://github.com/esdrasrenan/sistema-de-chamados/releases/latest/download/latest.json"
|
||||||
],
|
],
|
||||||
"dialog": true,
|
"dialog": true,
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM1RkE0NkZFMTM0NTA4N0MKUldSOENFVVQva2I2eFZ5TTA0WitpZGRPUXVmbUtjNXNleXlYb1ZKWVlERlZiVzYybUptT1pINlgK"
|
"active": true,
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM2NTA0QUY2NzRFQ0UzQzYKUldURzQreDA5a3BReGxMTTFQSUpLZmdJRXZSSm1ldzBQTmFpUE5lS0xFeTZTb2Yzb1NJUFZnOTUK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
"targets": [
|
"targets": [
|
||||||
"deb",
|
"deb",
|
||||||
"rpm",
|
"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 { createRoot } from "react-dom/client"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { Store } from "@tauri-apps/plugin-store"
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
|
|
@ -45,6 +46,12 @@ type MachineRegisterResponse = {
|
||||||
machineToken: string
|
machineToken: string
|
||||||
machineEmail?: string | null
|
machineEmail?: string | null
|
||||||
expiresAt?: number | null
|
expiresAt?: number | null
|
||||||
|
persona?: string | null
|
||||||
|
assignedUserId?: string | null
|
||||||
|
collaborator?: {
|
||||||
|
email: string
|
||||||
|
name?: string | null
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig = {
|
type AgentConfig = {
|
||||||
|
|
@ -54,6 +61,10 @@ type AgentConfig = {
|
||||||
machineEmail?: string | null
|
machineEmail?: string | null
|
||||||
collaboratorEmail?: string | null
|
collaboratorEmail?: string | null
|
||||||
collaboratorName?: string | null
|
collaboratorName?: string | null
|
||||||
|
accessRole: "collaborator" | "manager"
|
||||||
|
assignedUserId?: string | null
|
||||||
|
assignedUserEmail?: string | null
|
||||||
|
assignedUserName?: string | null
|
||||||
apiBaseUrl: string
|
apiBaseUrl: string
|
||||||
appUrl: string
|
appUrl: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
|
@ -129,7 +140,14 @@ function App() {
|
||||||
const [company, setCompany] = useState("")
|
const [company, setCompany] = useState("")
|
||||||
const [collabEmail, setCollabEmail] = useState("")
|
const [collabEmail, setCollabEmail] = useState("")
|
||||||
const [collabName, setCollabName] = useState("")
|
const [collabName, setCollabName] = useState("")
|
||||||
|
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">("collaborator")
|
||||||
const [updating, setUpdating] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -140,6 +158,7 @@ function App() {
|
||||||
setToken(t)
|
setToken(t)
|
||||||
const cfg = await readConfig(s)
|
const cfg = await readConfig(s)
|
||||||
setConfig(cfg)
|
setConfig(cfg)
|
||||||
|
setAccessRole(cfg?.accessRole ?? "collaborator")
|
||||||
if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail)
|
if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail)
|
||||||
if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName)
|
if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName)
|
||||||
if (!t) {
|
if (!t) {
|
||||||
|
|
@ -147,7 +166,7 @@ function App() {
|
||||||
setProfile(p)
|
setProfile(p)
|
||||||
}
|
}
|
||||||
setStatus(t ? "online" : null)
|
setStatus(t ? "online" : null)
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError("Falha ao carregar estado do agente.")
|
setError("Falha ao carregar estado do agente.")
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
@ -161,7 +180,8 @@ function App() {
|
||||||
const normalizedName = name.length > 0 ? name : null
|
const normalizedName = name.length > 0 ? name : null
|
||||||
if (
|
if (
|
||||||
config.collaboratorEmail === normalizedEmail &&
|
config.collaboratorEmail === normalizedEmail &&
|
||||||
config.collaboratorName === normalizedName
|
config.collaboratorName === normalizedName &&
|
||||||
|
config.accessRole === accessRole
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -169,10 +189,11 @@ function App() {
|
||||||
...config,
|
...config,
|
||||||
collaboratorEmail: normalizedEmail,
|
collaboratorEmail: normalizedEmail,
|
||||||
collaboratorName: normalizedName,
|
collaboratorName: normalizedName,
|
||||||
|
accessRole,
|
||||||
}
|
}
|
||||||
setConfig(nextConfig)
|
setConfig(nextConfig)
|
||||||
writeConfig(store, nextConfig).catch((err) => console.error("Falha ao atualizar colaborador", err))
|
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(() => {
|
useEffect(() => {
|
||||||
if (!store || !config) return
|
if (!store || !config) return
|
||||||
|
|
@ -195,7 +216,7 @@ function App() {
|
||||||
return appUrl
|
return appUrl
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
}, [config?.appUrl, appUrl])
|
}, [config?.appUrl])
|
||||||
|
|
||||||
async function register() {
|
async function register() {
|
||||||
if (!profile) return
|
if (!profile) return
|
||||||
|
|
@ -205,6 +226,16 @@ function App() {
|
||||||
const collaboratorPayload = collabEmail.trim()
|
const collaboratorPayload = collabEmail.trim()
|
||||||
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
|
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
|
||||||
: 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 = {
|
const payload = {
|
||||||
provisioningSecret: provisioningSecret.trim(),
|
provisioningSecret: provisioningSecret.trim(),
|
||||||
tenantId: tenantId.trim() || undefined,
|
tenantId: tenantId.trim() || undefined,
|
||||||
|
|
@ -213,7 +244,9 @@ function App() {
|
||||||
os: profile.os,
|
os: profile.os,
|
||||||
macAddresses: profile.macAddresses,
|
macAddresses: profile.macAddresses,
|
||||||
serialNumbers: profile.serialNumbers,
|
serialNumbers: profile.serialNumbers,
|
||||||
metadata: { inventory: profile.inventory, metrics: profile.metrics, collaborator: collaboratorPayload },
|
metadata: metadataPayload,
|
||||||
|
accessRole,
|
||||||
|
collaborator: collaboratorPayload,
|
||||||
registeredBy: "desktop-agent",
|
registeredBy: "desktop-agent",
|
||||||
}
|
}
|
||||||
const res = await fetch(`${apiBaseUrl}/api/machines/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })
|
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,
|
machineEmail: data.machineEmail ?? null,
|
||||||
collaboratorEmail: collaboratorPayload?.email ?? null,
|
collaboratorEmail: collaboratorPayload?.email ?? null,
|
||||||
collaboratorName: collaboratorPayload?.name ?? 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,
|
apiBaseUrl,
|
||||||
appUrl,
|
appUrl,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|
@ -248,16 +285,19 @@ function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSystem() {
|
const openSystem = useCallback(() => {
|
||||||
if (!token || !config) return
|
if (!token) return
|
||||||
const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}`
|
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
|
window.location.href = url
|
||||||
}
|
}, [token, config?.accessRole, accessRole, resolvedAppUrl])
|
||||||
|
|
||||||
async function reprovision() {
|
async function reprovision() {
|
||||||
if (!store) return
|
if (!store) return
|
||||||
await store.delete("token"); await store.delete("config"); await store.save()
|
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")
|
const p = await invoke<MachineProfile>("collect_machine_profile")
|
||||||
setProfile(p)
|
setProfile(p)
|
||||||
}
|
}
|
||||||
|
|
@ -269,9 +309,12 @@ function App() {
|
||||||
const collaboratorPayload = collabEmail.trim()
|
const collaboratorPayload = collabEmail.trim()
|
||||||
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
|
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
|
||||||
: undefined
|
: undefined
|
||||||
|
const collaboratorInventory = collaboratorPayload
|
||||||
|
? { ...collaboratorPayload, role: accessRole }
|
||||||
|
: undefined
|
||||||
const inventoryPayload: Record<string, unknown> = { ...profile.inventory }
|
const inventoryPayload: Record<string, unknown> = { ...profile.inventory }
|
||||||
if (collaboratorPayload) {
|
if (collaboratorInventory) {
|
||||||
inventoryPayload.collaborator = collaboratorPayload
|
inventoryPayload.collaborator = collaboratorInventory
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
machineToken: token,
|
machineToken: token,
|
||||||
|
|
@ -296,27 +339,61 @@ function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates(auto = false) {
|
||||||
try {
|
try {
|
||||||
setUpdating(true)
|
if (!auto) {
|
||||||
|
setUpdating(true)
|
||||||
|
setUpdateInfo({ tone: "info", message: "Procurando por atualizações..." })
|
||||||
|
}
|
||||||
const { check } = await import("@tauri-apps/plugin-updater")
|
const { check } = await import("@tauri-apps/plugin-updater")
|
||||||
const update = await check()
|
type UpdateResult = {
|
||||||
if (update && (update as any).available) {
|
available?: boolean
|
||||||
// download and install then relaunch
|
version?: string
|
||||||
await (update as any).downloadAndInstall()
|
downloadAndInstall?: () => Promise<void>
|
||||||
const { relaunch } = await import("@tauri-apps/plugin-process")
|
}
|
||||||
await relaunch()
|
const update = (await check()) as UpdateResult | null
|
||||||
} else {
|
if (update?.available) {
|
||||||
alert("Nenhuma atualização disponível.")
|
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) {
|
} catch (error) {
|
||||||
console.error("Falha ao verificar atualizações", 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 {
|
} 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 (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center p-6">
|
<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">
|
<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>
|
<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)} />
|
<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>
|
||||||
|
<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">
|
<div className="grid gap-2">
|
||||||
<label className="text-sm font-medium">Colaborador (e-mail)</label>
|
<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)} />
|
<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>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="label">Atualizações</label>
|
<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")}>
|
<button
|
||||||
<RefreshCw className="size-4" /> Verificar atualizações
|
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>
|
</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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
@ -452,7 +559,7 @@ function App() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { MutationCtx } from "./_generated/server"
|
||||||
|
|
||||||
const DEFAULT_TENANT_ID = "tenant-atlas"
|
const DEFAULT_TENANT_ID = "tenant-atlas"
|
||||||
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
||||||
|
const ALLOWED_MACHINE_PERSONAS = new Set(["collaborator", "manager"])
|
||||||
|
|
||||||
type NormalizedIdentifiers = {
|
type NormalizedIdentifiers = {
|
||||||
macs: string[]
|
macs: string[]
|
||||||
|
|
@ -327,6 +328,11 @@ export const register = mutation({
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
status: "online",
|
status: "online",
|
||||||
registeredBy: args.registeredBy ?? existing.registeredBy,
|
registeredBy: args.registeredBy ?? existing.registeredBy,
|
||||||
|
persona: existing.persona,
|
||||||
|
assignedUserId: existing.assignedUserId,
|
||||||
|
assignedUserEmail: existing.assignedUserEmail,
|
||||||
|
assignedUserName: existing.assignedUserName,
|
||||||
|
assignedUserRole: existing.assignedUserRole,
|
||||||
})
|
})
|
||||||
machineId = existing._id
|
machineId = existing._id
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -347,6 +353,11 @@ export const register = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
registeredBy: args.registeredBy,
|
registeredBy: args.registeredBy,
|
||||||
|
persona: undefined,
|
||||||
|
assignedUserId: undefined,
|
||||||
|
assignedUserEmail: undefined,
|
||||||
|
assignedUserName: undefined,
|
||||||
|
assignedUserRole: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -447,6 +458,11 @@ export const upsertInventory = mutation({
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
status: args.metrics ? "online" : existing.status ?? "unknown",
|
status: args.metrics ? "online" : existing.status ?? "unknown",
|
||||||
registeredBy: args.registeredBy ?? existing.registeredBy,
|
registeredBy: args.registeredBy ?? existing.registeredBy,
|
||||||
|
persona: existing.persona,
|
||||||
|
assignedUserId: existing.assignedUserId,
|
||||||
|
assignedUserEmail: existing.assignedUserEmail,
|
||||||
|
assignedUserName: existing.assignedUserName,
|
||||||
|
assignedUserRole: existing.assignedUserRole,
|
||||||
})
|
})
|
||||||
machineId = existing._id
|
machineId = existing._id
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -467,6 +483,11 @@ export const upsertInventory = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
registeredBy: args.registeredBy,
|
registeredBy: args.registeredBy,
|
||||||
|
persona: undefined,
|
||||||
|
assignedUserId: undefined,
|
||||||
|
assignedUserEmail: undefined,
|
||||||
|
assignedUserName: undefined,
|
||||||
|
assignedUserRole: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -570,6 +591,11 @@ export const resolveToken = mutation({
|
||||||
architecture: machine.architecture,
|
architecture: machine.architecture,
|
||||||
authUserId: machine.authUserId,
|
authUserId: machine.authUserId,
|
||||||
authEmail: machine.authEmail,
|
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,
|
status: machine.status,
|
||||||
lastHeartbeatAt: machine.lastHeartbeatAt,
|
lastHeartbeatAt: machine.lastHeartbeatAt,
|
||||||
metadata: machine.metadata,
|
metadata: machine.metadata,
|
||||||
|
|
@ -646,6 +672,11 @@ export const listByTenant = query({
|
||||||
serialNumbers: machine.serialNumbers,
|
serialNumbers: machine.serialNumbers,
|
||||||
authUserId: machine.authUserId ?? null,
|
authUserId: machine.authUserId ?? null,
|
||||||
authEmail: machine.authEmail ?? 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,
|
status: derivedStatus,
|
||||||
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
||||||
heartbeatAgeMs: machine.lastHeartbeatAt ? now - 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({
|
export const linkAuthAccount = mutation({
|
||||||
args: {
|
args: {
|
||||||
machineId: v.id("machines"),
|
machineId: v.id("machines"),
|
||||||
|
|
@ -710,7 +875,7 @@ export const rename = mutation({
|
||||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||||
}
|
}
|
||||||
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
|
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)) {
|
if (!STAFF.has(normalizedRole)) {
|
||||||
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
|
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")
|
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||||
}
|
}
|
||||||
const role = (actor.role ?? "AGENT").toUpperCase()
|
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)) {
|
if (!STAFF.has(role)) {
|
||||||
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
|
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 SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
|
||||||
|
|
||||||
const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
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) {
|
function normalizeEmail(value: string) {
|
||||||
return value.trim().toLowerCase()
|
return value.trim().toLowerCase()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { ConvexError } from "convex/values"
|
||||||
import type { Id } from "./_generated/dataModel"
|
import type { Id } from "./_generated/dataModel"
|
||||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
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"
|
const MANAGER_ROLE = "MANAGER"
|
||||||
|
|
||||||
type Ctx = QueryCtx | MutationCtx
|
type Ctx = QueryCtx | MutationCtx
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,11 @@ export default defineSchema({
|
||||||
companySlug: v.optional(v.string()),
|
companySlug: v.optional(v.string()),
|
||||||
authUserId: v.optional(v.string()),
|
authUserId: v.optional(v.string()),
|
||||||
authEmail: 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(),
|
hostname: v.string(),
|
||||||
osName: v.string(),
|
osName: v.string(),
|
||||||
osVersion: v.optional(v.string()),
|
osVersion: v.optional(v.string()),
|
||||||
|
|
@ -261,7 +266,8 @@ export default defineSchema({
|
||||||
})
|
})
|
||||||
.index("by_tenant", ["tenantId"])
|
.index("by_tenant", ["tenantId"])
|
||||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
.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({
|
machineTokens: defineTable({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import { Id, type Doc } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireStaff, requireUser } from "./rbac";
|
import { requireStaff, requireUser } from "./rbac";
|
||||||
|
|
||||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
|
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
|
||||||
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
|
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
|
||||||
const PAUSE_REASON_LABELS: Record<string, string> = {
|
const PAUSE_REASON_LABELS: Record<string, string> = {
|
||||||
NO_CONTACT: "Falta de contato",
|
NO_CONTACT: "Falta de contato",
|
||||||
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const ensureUser = mutation({
|
||||||
avatarUrl: v.optional(v.string()),
|
avatarUrl: v.optional(v.string()),
|
||||||
role: v.optional(v.string()),
|
role: v.optional(v.string()),
|
||||||
teams: v.optional(v.array(v.string())),
|
teams: v.optional(v.array(v.string())),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
|
|
@ -25,7 +26,8 @@ export const ensureUser = mutation({
|
||||||
(args.role && record.role !== args.role) ||
|
(args.role && record.role !== args.role) ||
|
||||||
(args.avatarUrl && record.avatarUrl !== args.avatarUrl) ||
|
(args.avatarUrl && record.avatarUrl !== args.avatarUrl) ||
|
||||||
record.name !== args.name ||
|
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) {
|
if (shouldPatch) {
|
||||||
await ctx.db.patch(record._id, {
|
await ctx.db.patch(record._id, {
|
||||||
|
|
@ -34,6 +36,7 @@ export const ensureUser = mutation({
|
||||||
avatarUrl: args.avatarUrl ?? record.avatarUrl,
|
avatarUrl: args.avatarUrl ?? record.avatarUrl,
|
||||||
name: args.name,
|
name: args.name,
|
||||||
teams: args.teams ?? record.teams,
|
teams: args.teams ?? record.teams,
|
||||||
|
companyId: args.companyId ?? record.companyId,
|
||||||
});
|
});
|
||||||
const updated = await ctx.db.get(record._id);
|
const updated = await ctx.db.get(record._id);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
|
|
@ -64,6 +67,7 @@ export const ensureUser = mutation({
|
||||||
avatarUrl: args.avatarUrl,
|
avatarUrl: args.avatarUrl,
|
||||||
role: args.role ?? "AGENT",
|
role: args.role ?? "AGENT",
|
||||||
teams: args.teams ?? [],
|
teams: args.teams ?? [],
|
||||||
|
companyId: args.companyId,
|
||||||
});
|
});
|
||||||
return await ctx.db.get(id);
|
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:
|
- Onde foram feitas as mudanças principais:
|
||||||
- `apps/desktop/src/components/ui/tabs.tsx` (Tabs Radix + estilos shadcn-like)
|
- `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):
|
- 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.
|
- `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
|
### 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.
|
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/`
|
- Os artefatos ficam em: `apps/desktop/src-tauri/target/release/bundle/`
|
||||||
- No Linux: `.AppImage`/`.deb`/`.rpm` (conforme target)
|
- No Linux: `.AppImage`/`.deb`/`.rpm` (conforme target)
|
||||||
- No Windows/macOS: executável/instalador específicos do SO (para assinatura, usar chaves/AC, se desejado)
|
- 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
|
### 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.
|
- `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.
|
- `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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
@ -95,4 +105,3 @@ pnpm -C apps/desktop tauri build
|
||||||
- `ERR_PNPM_OUTDATED_LOCKFILE` no Desktop:
|
- `ERR_PNPM_OUTDATED_LOCKFILE` no Desktop:
|
||||||
- Atualize `pnpm-lock.yaml` no root após alterar dependências de `apps/desktop/package.json`.
|
- 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).
|
- 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.
|
- Marcação “Somente com alertas” para investigar postura.
|
||||||
|
|
||||||
## Painel de detalhes
|
## 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.
|
- 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.
|
- Discos e partições: nome, mount, FS, capacidade, livre.
|
||||||
- Inventário estendido (varia por SO):
|
- Inventário estendido (varia por SO):
|
||||||
- Linux: SMART (OK/ALERTA), `lspci`, `lsusb` (texto), `lsblk` (interno para discos).
|
- 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`).
|
- macOS: pacotes (`pkgutil`), serviços (`launchctl`).
|
||||||
- Postura/Alertas: CPU alta, serviço parado, SMART em falha com severidade e última avaliação.
|
- 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
|
## Exportação
|
||||||
- Copiar JSON: copia para a área de transferência todo o inventário exibido (métricas + inventário + alertas).
|
- 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/**",
|
".next/**",
|
||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
|
"apps/desktop/dist/**",
|
||||||
|
"apps/desktop/src-tauri/target/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"convex/_generated/**",
|
"convex/_generated/**",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { getCookieCache } from "better-auth/cookies"
|
||||||
|
|
||||||
const PUBLIC_PATHS = [/^\/login$/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/]
|
const PUBLIC_PATHS = [/^\/login$/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/]
|
||||||
const ADMIN_ONLY_PATHS = [/^\/admin(?:$|\/)/]
|
const ADMIN_ONLY_PATHS = [/^\/admin(?:$|\/)/]
|
||||||
const PORTAL_HOME = "/portal"
|
|
||||||
const APP_HOME = "/dashboard"
|
const APP_HOME = "/dashboard"
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
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")
|
role String @default("agent")
|
||||||
tenantId String?
|
tenantId String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
machinePersona String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions AuthSession[]
|
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([]),
|
serialNumbers: z.array(z.string()).default([]),
|
||||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
registeredBy: z.string().optional(),
|
registeredBy: z.string().optional(),
|
||||||
|
accessRole: z.enum(["collaborator", "manager"]).optional(),
|
||||||
|
collaborator: z
|
||||||
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => (data.macAddresses && data.macAddresses.length > 0) || (data.serialNumbers && data.serialNumbers.length > 0),
|
(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)
|
const client = new ConvexHttpClient(convexUrl)
|
||||||
|
|
||||||
try {
|
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, {
|
const registration = await client.mutation(api.machines.register, {
|
||||||
provisioningSecret: payload.provisioningSecret,
|
provisioningSecret: payload.provisioningSecret,
|
||||||
tenantId: payload.tenantId ?? DEFAULT_TENANT_ID,
|
tenantId,
|
||||||
companySlug: payload.companySlug ?? undefined,
|
companySlug: payload.companySlug ?? undefined,
|
||||||
hostname: payload.hostname,
|
hostname: payload.hostname,
|
||||||
os: payload.os,
|
os: payload.os,
|
||||||
macAddresses: payload.macAddresses,
|
macAddresses: payload.macAddresses,
|
||||||
serialNumbers: payload.serialNumbers,
|
serialNumbers: payload.serialNumbers,
|
||||||
metadata: payload.metadata,
|
metadata: metadataPayload,
|
||||||
registeredBy: payload.registeredBy,
|
registeredBy: payload.registeredBy,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -78,6 +113,7 @@ export async function POST(request: Request) {
|
||||||
tenantId: registration.tenantId ?? DEFAULT_TENANT_ID,
|
tenantId: registration.tenantId ?? DEFAULT_TENANT_ID,
|
||||||
hostname: payload.hostname,
|
hostname: payload.hostname,
|
||||||
machineToken: registration.machineToken,
|
machineToken: registration.machineToken,
|
||||||
|
persona,
|
||||||
})
|
})
|
||||||
|
|
||||||
await client.mutation(api.machines.linkAuthAccount, {
|
await client.mutation(api.machines.linkAuthAccount, {
|
||||||
|
|
@ -86,6 +122,34 @@ export async function POST(request: Request) {
|
||||||
authEmail: account.authEmail,
|
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(
|
return jsonWithCors(
|
||||||
{
|
{
|
||||||
machineId: registration.machineId,
|
machineId: registration.machineId,
|
||||||
|
|
@ -95,6 +159,9 @@ export async function POST(request: Request) {
|
||||||
machineToken: registration.machineToken,
|
machineToken: registration.machineToken,
|
||||||
machineEmail: account.authEmail,
|
machineEmail: account.authEmail,
|
||||||
expiresAt: registration.expiresAt,
|
expiresAt: registration.expiresAt,
|
||||||
|
persona: persona ?? null,
|
||||||
|
assignedUserId: assignedUserId ?? null,
|
||||||
|
collaborator: collaborator ?? null,
|
||||||
},
|
},
|
||||||
{ status: 201 },
|
{ status: 201 },
|
||||||
request.headers.get("origin"),
|
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)
|
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)
|
applyCorsHeaders(response, request.headers.get("origin"), CORS_METHODS)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
||||||
|
|
||||||
// Header with logo and brand bar
|
// Header with logo and brand bar
|
||||||
try {
|
try {
|
||||||
const logoPath = path.join(process.cwd(), "public", "rever-8.png")
|
const logoPath = path.join(process.cwd(), "public", "raven.png")
|
||||||
if (fs.existsSync(logoPath)) {
|
if (fs.existsSync(logoPath)) {
|
||||||
doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 })
|
doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export const metadata: Metadata = {
|
||||||
title: "Raven",
|
title: "Raven",
|
||||||
description: "Plataforma Raven da Rever",
|
description: "Plataforma Raven da Rever",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/rever-8.png",
|
icon: "/raven.png",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export function LoginPageClient() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image
|
<Image
|
||||||
src="/rever-8.png"
|
src="/raven.png"
|
||||||
alt="Logotipo Rever Tecnologia"
|
alt="Logotipo Rever Tecnologia"
|
||||||
width={110}
|
width={110}
|
||||||
height={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
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[machines.handshake] Falha ao autenticar máquina", 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 }>
|
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 }
|
extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
|
||||||
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
||||||
collaborator?: { email?: string; name?: string }
|
collaborator?: { email?: string; name?: string; role?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MachinesQueryItem = {
|
export type MachinesQueryItem = {
|
||||||
|
|
@ -135,6 +135,11 @@ export type MachinesQueryItem = {
|
||||||
serialNumbers: string[]
|
serialNumbers: string[]
|
||||||
authUserId: string | null
|
authUserId: string | null
|
||||||
authEmail: string | null
|
authEmail: string | null
|
||||||
|
persona: string | null
|
||||||
|
assignedUserId: string | null
|
||||||
|
assignedUserEmail: string | null
|
||||||
|
assignedUserName: string | null
|
||||||
|
assignedUserRole: string | null
|
||||||
status: string | null
|
status: string | null
|
||||||
lastHeartbeatAt: number | null
|
lastHeartbeatAt: number | null
|
||||||
heartbeatAgeMs: number | null
|
heartbeatAgeMs: number | null
|
||||||
|
|
@ -209,12 +214,6 @@ function formatPercent(value?: number | null) {
|
||||||
return `${normalized.toFixed(0)}%`
|
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 {
|
function readBool(source: unknown, key: string): boolean | undefined {
|
||||||
if (!source || typeof source !== "object") return undefined
|
if (!source || typeof source !== "object") return undefined
|
||||||
const value = (source as Record<string, unknown>)[key]
|
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)
|
// collaborator (from machine assignment or metadata)
|
||||||
type Collaborator = { email?: string; name?: string }
|
type Collaborator = { email?: string; name?: string; role?: string }
|
||||||
const collaborator: Collaborator | null = (() => {
|
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
|
if (!metadata || typeof metadata !== "object") return null
|
||||||
const inv = metadata as Record<string, unknown>
|
const inv = metadata as Record<string, unknown>
|
||||||
const c = inv["collaborator"]
|
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
|
return null
|
||||||
})()
|
}, [machine?.assignedUserEmail, machine?.assignedUserName, machine?.persona, machine?.assignedUserRole, metadata])
|
||||||
|
|
||||||
|
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
|
||||||
|
|
||||||
const companyName = (() => {
|
const companyName = (() => {
|
||||||
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
||||||
|
|
@ -513,6 +528,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
const [dialogQuery, setDialogQuery] = useState("")
|
const [dialogQuery, setDialogQuery] = useState("")
|
||||||
const [deleteDialog, setDeleteDialog] = useState(false)
|
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||||
const [deleting, setDeleting] = 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 jsonText = useMemo(() => {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: machine?.id,
|
id: machine?.id,
|
||||||
|
|
@ -535,6 +557,42 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
}, [jsonText, dialogQuery])
|
}, [jsonText, dialogQuery])
|
||||||
|
|
||||||
// removed copy/export inventory JSON buttons as requested
|
// 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 (
|
return (
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
|
|
@ -594,17 +652,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
) : null}
|
) : null}
|
||||||
{collaborator?.email ? (
|
{collaborator?.email ? (
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
<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>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{machine.authEmail ? (
|
{machine.authEmail ? (
|
||||||
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
|
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
|
||||||
<ClipboardCopy className="size-4" />
|
<ClipboardCopy className="size-4" />
|
||||||
Copiar e-mail
|
Copiar e-mail
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
|
||||||
|
<ShieldCheck className="size-4" />
|
||||||
|
Ajustar acesso
|
||||||
|
</Button>
|
||||||
{machine.registeredBy ? (
|
{machine.registeredBy ? (
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
<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}
|
Registrada via {machine.registeredBy}
|
||||||
|
|
@ -653,6 +715,42 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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">
|
<section className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Sincronização</h4>
|
<h4 className="text-sm font-semibold">Sincronização</h4>
|
||||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
<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 memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN)
|
||||||
const cpuPct = mm?.cpuUsagePercent ?? NaN
|
const cpuPct = mm?.cpuUsagePercent ?? NaN
|
||||||
const collaborator = (() => {
|
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
|
const inv = machine.inventory as unknown
|
||||||
if (!inv || typeof inv !== "object") return null
|
if (!inv || typeof inv !== "object") return null
|
||||||
const raw = (inv as Record<string, unknown>).collaborator
|
const raw = (inv as Record<string, unknown>).collaborator
|
||||||
if (!raw || typeof raw !== "object") return null
|
if (!raw || typeof raw !== "object") return null
|
||||||
const obj = raw as Record<string, unknown>
|
const obj = raw as Record<string, unknown>
|
||||||
const email = typeof obj.email === "string" ? obj.email : undefined
|
const email = typeof obj.email === "string" ? obj.email : undefined
|
||||||
const name = typeof obj.name === "string" ? obj.name : undefined
|
|
||||||
if (!email) return null
|
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
|
const companyLabel = companyName ?? machine.companySlug ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1427,7 +1536,7 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
||||||
</div>
|
</div>
|
||||||
{collaborator?.email ? (
|
{collaborator?.email ? (
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{collaborator.name ? `${collaborator.name} · ` : ""}
|
{persona}: {collaborator.name ? `${collaborator.name} · ` : ""}
|
||||||
{collaborator.email}
|
{collaborator.email}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -23,17 +23,21 @@ const navItems = [
|
||||||
export function PortalShell({ children }: PortalShellProps) {
|
export function PortalShell({ children }: PortalShellProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { session } = useAuth()
|
const { session, machineContext } = useAuth()
|
||||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
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 initials = useMemo(() => {
|
||||||
const name = session?.user.name || session?.user.email || "Cliente"
|
const name = displayName || displayEmail || "Cliente"
|
||||||
return name
|
return name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map((part) => part.charAt(0).toUpperCase())
|
.map((part) => part.charAt(0).toUpperCase())
|
||||||
.join("")
|
.join("")
|
||||||
}, [session?.user.name, session?.user.email])
|
}, [displayName, displayEmail])
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut() {
|
||||||
if (isSigningOut) return
|
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-3">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Avatar className="size-9 border border-slate-200">
|
<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>
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col leading-tight">
|
<div className="flex flex-col leading-tight">
|
||||||
<span className="font-semibold text-neutral-900">{session?.user.name ?? "Cliente"}</span>
|
<span className="font-semibold text-neutral-900">{displayName}</span>
|
||||||
<span className="text-xs text-neutral-500">{session?.user.email ?? ""}</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>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export type AppSession = {
|
||||||
role: string
|
role: string
|
||||||
tenantId: string | null
|
tenantId: string | null
|
||||||
avatarUrl: string | null
|
avatarUrl: string | null
|
||||||
|
machinePersona?: string | null
|
||||||
}
|
}
|
||||||
session: {
|
session: {
|
||||||
id: string
|
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({
|
const authClient = createAuthClient({
|
||||||
plugins: [customSessionClient<AppAuth>()],
|
plugins: [customSessionClient<AppAuth>()],
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
|
|
@ -40,6 +52,7 @@ type AuthContextValue = {
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
isStaff: boolean
|
isStaff: boolean
|
||||||
isCustomer: boolean
|
isCustomer: boolean
|
||||||
|
machineContext: MachineContext | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue>({
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
|
@ -50,6 +63,7 @@ const AuthContext = createContext<AuthContextValue>({
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isStaff: false,
|
isStaff: false,
|
||||||
isCustomer: false,
|
isCustomer: false,
|
||||||
|
machineContext: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
|
@ -62,15 +76,68 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { data: session, isPending } = useSession()
|
const { data: session, isPending } = useSession()
|
||||||
const ensureUser = useMutation(api.users.ensureUser)
|
const ensureUser = useMutation(api.users.ensureUser)
|
||||||
const [convexUserId, setConvexUserId] = useState<string | null>(null)
|
const [convexUserId, setConvexUserId] = useState<string | null>(null)
|
||||||
|
const [machineContext, setMachineContext] = useState<MachineContext | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.user) {
|
if (!session?.user || session.user.role === "machine") {
|
||||||
setConvexUserId(null)
|
setConvexUserId(null)
|
||||||
}
|
}
|
||||||
}, [session?.user])
|
}, [session?.user])
|
||||||
|
|
||||||
useEffect(() => {
|
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()
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
|
@ -99,19 +166,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ensureUser, session?.user?.email, session?.user?.tenantId, session?.user?.role, convexUserId])
|
}, [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>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
session: session ?? null,
|
session: session ?? null,
|
||||||
isLoading: isPending,
|
isLoading: isPending,
|
||||||
convexUserId,
|
convexUserId: effectiveConvexUserId,
|
||||||
role: normalizedRole,
|
role: normalizedRole,
|
||||||
isAdmin: isAdmin(normalizedRole),
|
isAdmin: isAdmin(normalizedRole),
|
||||||
isStaff: isStaff(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>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ export const auth = betterAuth({
|
||||||
type: "string",
|
type: "string",
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
machinePersona: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
input: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
|
@ -76,6 +81,7 @@ export const auth = betterAuth({
|
||||||
role: ((user as { role?: string }).role ?? "agent").toLowerCase(),
|
role: ((user as { role?: string }).role ?? "agent").toLowerCase(),
|
||||||
tenantId: (user as { tenantId?: string | null }).tenantId ?? null,
|
tenantId: (user as { tenantId?: string | null }).tenantId ?? null,
|
||||||
avatarUrl: (user as { avatarUrl?: string | null }).avatarUrl ?? 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
|
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator"] as const
|
||||||
|
|
||||||
const ADMIN_ROLE = "admin"
|
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]
|
export type RoleOption = (typeof ROLE_OPTIONS)[number]
|
||||||
|
|
||||||
|
|
@ -16,3 +17,7 @@ export function isAdmin(role?: string | null) {
|
||||||
export function isStaff(role?: string | null) {
|
export function isStaff(role?: string | null) {
|
||||||
return STAFF_ROLES.has(normalizeRole(role) ?? "")
|
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
|
tenantId: string
|
||||||
hostname: string
|
hostname: string
|
||||||
machineToken: string
|
machineToken: string
|
||||||
|
persona?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
|
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 machineEmail = `machine-${machineId}@machines.local`
|
||||||
const context = await auth.$context
|
const context = await auth.$context
|
||||||
|
|
||||||
|
|
@ -22,12 +23,14 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
|
||||||
name: machineName,
|
name: machineName,
|
||||||
tenantId,
|
tenantId,
|
||||||
role: "machine",
|
role: "machine",
|
||||||
|
machinePersona: persona ?? null,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
email: machineEmail,
|
email: machineEmail,
|
||||||
name: machineName,
|
name: machineName,
|
||||||
role: "machine",
|
role: "machine",
|
||||||
tenantId,
|
tenantId,
|
||||||
|
machinePersona: persona ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ export type MachineSessionContext = {
|
||||||
companyId: Id<"companies"> | null
|
companyId: Id<"companies"> | null
|
||||||
companySlug: string | null
|
companySlug: string | null
|
||||||
metadata: Record<string, unknown> | null
|
metadata: Record<string, unknown> | null
|
||||||
|
persona: string | null
|
||||||
|
assignedUserId: Id<"users"> | null
|
||||||
|
assignedUserEmail: string | null
|
||||||
|
assignedUserName: string | null
|
||||||
|
assignedUserRole: string | null
|
||||||
}
|
}
|
||||||
headers: Headers
|
headers: Headers
|
||||||
response: unknown
|
response: unknown
|
||||||
|
|
@ -41,6 +46,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
|
||||||
tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID,
|
tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID,
|
||||||
hostname: resolved.machine.hostname,
|
hostname: resolved.machine.hostname,
|
||||||
machineToken,
|
machineToken,
|
||||||
|
persona: (resolved.machine.persona ?? null) ?? undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
await client.mutation(api.machines.linkAuthAccount, {
|
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,
|
companyId: (resolved.machine.companyId ?? null) as Id<"companies"> | null,
|
||||||
companySlug: resolved.machine.companySlug ?? null,
|
companySlug: resolved.machine.companySlug ?? null,
|
||||||
metadata: (resolved.machine.metadata ?? null) as Record<string, unknown> | 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,
|
headers: signIn.headers,
|
||||||
response: signIn.response,
|
response: signIn.response,
|
||||||
|
|
|
||||||