feat: melhorar gerenciamento de acesso remoto de máquinas

This commit is contained in:
Esdras Renan 2025-10-28 11:45:16 -03:00
parent 714b199879
commit 192a5c2909
5 changed files with 664 additions and 261 deletions

View file

@ -18,6 +18,10 @@ export const ensureDefaults = mutation({
await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" }); await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" });
return (await ctx.db.get(queue._id)) ?? queue; return (await ctx.db.get(queue._id)) ?? queue;
} }
if (queue.name === "Field Services" || queue.slug === "field-services") {
await ctx.db.patch(queue._id, { name: "Visitas", slug: "visitas" });
return (await ctx.db.get(queue._id)) ?? queue;
}
return queue; return queue;
}) })
); );
@ -25,7 +29,7 @@ export const ensureDefaults = mutation({
const queues = [ const queues = [
{ name: "Chamados", slug: "chamados" }, { name: "Chamados", slug: "chamados" },
{ name: "Laboratório", slug: "laboratorio" }, { name: "Laboratório", slug: "laboratorio" },
{ name: "Field Services", slug: "field-services" }, { name: "Visitas", slug: "visitas" },
]; ];
for (const q of queues) { for (const q of queues) {
await ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined }); await ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined });
@ -33,4 +37,3 @@ export const ensureDefaults = mutation({
} }
}, },
}); });

View file

@ -1367,6 +1367,118 @@ export const toggleActive = mutation({
}, },
}) })
type RemoteAccessEntry = {
id: string
provider: string
identifier: string
url: string | null
notes: string | null
lastVerifiedAt: number | null
metadata: Record<string, unknown> | null
}
function createRemoteAccessId() {
return `ra_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36)}`
}
function coerceString(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
return null
}
function coerceNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const trimmed = value.trim()
if (!trimmed) return null
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function normalizeRemoteAccessEntry(raw: unknown): RemoteAccessEntry | null {
if (!raw) return null
if (typeof raw === "string") {
const trimmed = raw.trim()
if (!trimmed) return null
const isUrl = /^https?:\/\//i.test(trimmed)
return {
id: createRemoteAccessId(),
provider: "Remoto",
identifier: isUrl ? trimmed : trimmed,
url: isUrl ? trimmed : null,
notes: null,
lastVerifiedAt: null,
metadata: null,
}
}
if (typeof raw !== "object") return null
const record = raw as Record<string, unknown>
const provider =
coerceString(record.provider) ??
coerceString(record.tool) ??
coerceString(record.vendor) ??
coerceString(record.name) ??
"Remoto"
const identifier =
coerceString(record.identifier) ??
coerceString(record.code) ??
coerceString(record.id) ??
coerceString(record.accessId)
const url =
coerceString(record.url) ??
coerceString(record.link) ??
coerceString(record.remoteUrl) ??
coerceString(record.console) ??
coerceString(record.viewer) ??
null
const resolvedIdentifier = identifier ?? url ?? "Acesso remoto"
const notes = coerceString(record.notes) ?? coerceString(record.note) ?? coerceString(record.description) ?? coerceString(record.obs) ?? null
const timestamp =
coerceNumber(record.lastVerifiedAt) ??
coerceNumber(record.verifiedAt) ??
coerceNumber(record.checkedAt) ??
coerceNumber(record.updatedAt) ??
null
const id = coerceString(record.id) ?? createRemoteAccessId()
const metadata =
record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata)
? (record.metadata as Record<string, unknown>)
: null
return {
id,
provider,
identifier: resolvedIdentifier,
url,
notes,
lastVerifiedAt: timestamp,
metadata,
}
}
function normalizeRemoteAccessList(raw: unknown): RemoteAccessEntry[] {
const source = Array.isArray(raw) ? raw : raw ? [raw] : []
const seen = new Set<string>()
const entries: RemoteAccessEntry[] = []
for (const item of source) {
const entry = normalizeRemoteAccessEntry(item)
if (!entry) continue
let nextId = entry.id
while (seen.has(nextId)) {
nextId = createRemoteAccessId()
}
seen.add(nextId)
entries.push(nextId === entry.id ? entry : { ...entry, id: nextId })
}
return entries
}
export const updateRemoteAccess = mutation({ export const updateRemoteAccess = mutation({
args: { args: {
machineId: v.id("machines"), machineId: v.id("machines"),
@ -1375,9 +1487,11 @@ export const updateRemoteAccess = mutation({
identifier: v.optional(v.string()), identifier: v.optional(v.string()),
url: v.optional(v.string()), url: v.optional(v.string()),
notes: v.optional(v.string()), notes: v.optional(v.string()),
action: v.optional(v.string()),
entryId: v.optional(v.string()),
clear: v.optional(v.boolean()), clear: v.optional(v.boolean()),
}, },
handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, clear }) => { handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, action, entryId, clear }) => {
const machine = await ctx.db.get(machineId) const machine = await ctx.db.get(machineId)
if (!machine) { if (!machine) {
throw new ConvexError("Máquina não encontrada") throw new ConvexError("Máquina não encontrada")
@ -1393,11 +1507,49 @@ export const updateRemoteAccess = mutation({
throw new ConvexError("Somente administradores e agentes podem ajustar o acesso remoto.") throw new ConvexError("Somente administradores e agentes podem ajustar o acesso remoto.")
} }
if (clear) { const actionMode = (() => {
if (clear) return "clear" as const
const normalized = (action ?? "").toLowerCase()
if (normalized === "clear") return "clear" as const
if (normalized === "delete" || normalized === "remove") return "delete" as const
return "upsert" as const
})()
const existingEntries = normalizeRemoteAccessList(machine.remoteAccess)
if (actionMode === "clear") {
await ctx.db.patch(machineId, { remoteAccess: null, updatedAt: Date.now() }) await ctx.db.patch(machineId, { remoteAccess: null, updatedAt: Date.now() })
return { remoteAccess: null } return { remoteAccess: null }
} }
if (actionMode === "delete") {
const trimmedEntryId = coerceString(entryId)
const trimmedProvider = coerceString(provider)
const trimmedIdentifier = coerceString(identifier)
let target: RemoteAccessEntry | undefined
if (trimmedEntryId) {
target = existingEntries.find((entry) => entry.id === trimmedEntryId)
}
if (!target && trimmedProvider && trimmedIdentifier) {
target = existingEntries.find(
(entry) => entry.provider === trimmedProvider && entry.identifier === trimmedIdentifier
)
}
if (!target && trimmedIdentifier) {
target = existingEntries.find((entry) => entry.identifier === trimmedIdentifier)
}
if (!target && trimmedProvider) {
target = existingEntries.find((entry) => entry.provider === trimmedProvider)
}
if (!target) {
throw new ConvexError("Entrada de acesso remoto não encontrada.")
}
const nextEntries = existingEntries.filter((entry) => entry.id !== target!.id)
const nextValue = nextEntries.length > 0 ? nextEntries : null
await ctx.db.patch(machineId, { remoteAccess: nextValue, updatedAt: Date.now() })
return { remoteAccess: nextValue }
}
const trimmedProvider = (provider ?? "").trim() const trimmedProvider = (provider ?? "").trim()
const trimmedIdentifier = (identifier ?? "").trim() const trimmedIdentifier = (identifier ?? "").trim()
if (!trimmedProvider || !trimmedIdentifier) { if (!trimmedProvider || !trimmedIdentifier) {
@ -1422,8 +1574,15 @@ export const updateRemoteAccess = mutation({
const cleanedNotes = notes?.trim() ? notes.trim() : null const cleanedNotes = notes?.trim() ? notes.trim() : null
const lastVerifiedAt = Date.now() const lastVerifiedAt = Date.now()
const targetEntryId =
coerceString(entryId) ??
existingEntries.find(
(entry) => entry.provider === trimmedProvider && entry.identifier === trimmedIdentifier
)?.id ??
createRemoteAccessId()
const remoteAccess = { const updatedEntry: RemoteAccessEntry = {
id: targetEntryId,
provider: trimmedProvider, provider: trimmedProvider,
identifier: trimmedIdentifier, identifier: trimmedIdentifier,
url: normalizedUrl, url: normalizedUrl,
@ -1438,12 +1597,21 @@ export const updateRemoteAccess = mutation({
}, },
} }
const existingIndex = existingEntries.findIndex((entry) => entry.id === targetEntryId)
let nextEntries: RemoteAccessEntry[]
if (existingIndex >= 0) {
nextEntries = [...existingEntries]
nextEntries[existingIndex] = updatedEntry
} else {
nextEntries = [...existingEntries, updatedEntry]
}
await ctx.db.patch(machineId, { await ctx.db.patch(machineId, {
remoteAccess, remoteAccess: nextEntries,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
return { remoteAccess } return { remoteAccess: nextEntries }
}, },
}) })

View file

@ -7,8 +7,8 @@ import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcru
export const runtime = "nodejs" export const runtime = "nodejs"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default function AdminMachineDetailsPage({ params }: { params: { id: string } }) { export default async function AdminMachineDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = params const { id } = await params
return ( return (
<AppShell header={<SiteHeader title="Detalhe da máquina" lead="Inventário e métricas da máquina selecionada." />}> <AppShell header={<SiteHeader title="Detalhe da máquina" lead="Inventário e métricas da máquina selecionada." />}>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6"> <div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">

View file

@ -4,6 +4,7 @@ import { ConvexHttpClient } from "convex/browser"
import { assertStaffSession } from "@/lib/auth-server" import { assertStaffSession } from "@/lib/auth-server"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
export const runtime = "nodejs" export const runtime = "nodejs"
@ -13,7 +14,8 @@ const schema = z.object({
identifier: z.string().optional(), identifier: z.string().optional(),
url: z.string().optional(), url: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
action: z.enum(["save", "clear"]).optional(), entryId: z.string().optional(),
action: z.enum(["save", "upsert", "clear", "delete", "remove"]).optional(),
}) })
export async function POST(request: Request) { export async function POST(request: Request) {
@ -21,9 +23,6 @@ export async function POST(request: Request) {
if (!session) { if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
} }
if (!session.user.id) {
return NextResponse.json({ error: "Sessão inválida" }, { status: 400 })
}
let parsed: z.infer<typeof schema> let parsed: z.infer<typeof schema>
try { try {
@ -41,49 +40,103 @@ export async function POST(request: Request) {
const client = new ConvexHttpClient(convexUrl) const client = new ConvexHttpClient(convexUrl)
try { try {
const action = parsed.action ?? "save" const tenantId =
session.user.tenantId ??
process.env.SYNC_TENANT_ID ??
process.env.SEED_USER_TENANT ??
"tenant-atlas"
const ensured = (await client.mutation(api.users.ensureUser, {
tenantId,
email: session.user.email,
name: session.user.name ?? session.user.email,
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role.toUpperCase(),
})) as { _id?: Id<"users"> } | null
if (action === "clear") { const actorId = ensured?._id as Id<"users"> | undefined
const result = (await (client as unknown as { mutation: (name: string, args: Record<string, unknown>) => Promise<unknown> }).mutation("machines:updateRemoteAccess", { if (!actorId) {
machineId: parsed.machineId as Id<"machines">, return NextResponse.json(
actorId: session.user.id as Id<"users">, { error: "Usuário não encontrado no Convex para executar esta ação." },
clear: true, { status: 403 }
})) as { remoteAccess?: unknown } | null )
return NextResponse.json({ ok: true, remoteAccess: result?.remoteAccess ?? null })
} }
const actionRaw = (parsed.action ?? "save").toLowerCase()
const normalizedAction =
actionRaw === "clear"
? "clear"
: actionRaw === "delete" || actionRaw === "remove"
? "delete"
: "upsert"
const provider = (parsed.provider ?? "").trim() const provider = (parsed.provider ?? "").trim()
const identifier = (parsed.identifier ?? "").trim() const identifier = (parsed.identifier ?? "").trim()
if (!provider || !identifier) { const notes = (parsed.notes ?? "").trim()
return NextResponse.json({ error: "Informe o provedor e o identificador do acesso remoto." }, { status: 400 })
}
let normalizedUrl: string | undefined let normalizedUrl: string | undefined
const rawUrl = (parsed.url ?? "").trim() if (normalizedAction === "upsert") {
if (rawUrl.length > 0) { if (!provider || !identifier) {
const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}` return NextResponse.json({ error: "Informe o provedor e o identificador do acesso remoto." }, { status: 400 })
try { }
new URL(candidate) const rawUrl = (parsed.url ?? "").trim()
normalizedUrl = candidate if (rawUrl.length > 0) {
} catch { const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`
return NextResponse.json({ error: "URL inválida. Informe um endereço iniciado com http:// ou https://." }, { status: 422 }) try {
new URL(candidate)
normalizedUrl = candidate
} catch {
return NextResponse.json({ error: "URL inválida. Informe um endereço iniciado com http:// ou https://." }, { status: 422 })
}
} }
} }
const notes = (parsed.notes ?? "").trim() const mutationArgs: Record<string, unknown> = {
const result = (await (client as unknown as { mutation: (name: string, args: Record<string, unknown>) => Promise<unknown> }).mutation("machines:updateRemoteAccess", {
machineId: parsed.machineId as Id<"machines">, machineId: parsed.machineId as Id<"machines">,
actorId: session.user.id as Id<"users">, actorId,
provider, action: normalizedAction,
identifier, }
url: normalizedUrl,
notes: notes.length ? notes : undefined, if (parsed.entryId) {
})) as { remoteAccess?: unknown } | null mutationArgs.entryId = parsed.entryId
}
if (normalizedAction === "clear") {
mutationArgs.clear = true
} else {
if (provider) mutationArgs.provider = provider
if (identifier) mutationArgs.identifier = identifier
if (normalizedUrl !== undefined) mutationArgs.url = normalizedUrl
if (notes.length) mutationArgs.notes = notes
}
const result = (await (client as unknown as { mutation: (name: string, args: Record<string, unknown>) => Promise<unknown> }).mutation(
"machines:updateRemoteAccess",
mutationArgs
)) as { remoteAccess?: unknown } | null
return NextResponse.json({ ok: true, remoteAccess: result?.remoteAccess ?? null }) return NextResponse.json({ ok: true, remoteAccess: result?.remoteAccess ?? null })
} catch (error) { } catch (error) {
console.error("[machines.remote-access]", error) console.error("[machines.remote-access]", error)
return NextResponse.json({ error: "Falha ao atualizar acesso remoto" }, { status: 500 }) const detail = error instanceof Error ? error.message : null
const isOutdatedConvex =
typeof detail === "string" && detail.includes("extra field `action`")
if (isOutdatedConvex) {
return NextResponse.json(
{
error: "Backend do Convex desatualizado",
detail: "Recompile/deploy as funções Convex (ex.: `pnpm convex:dev` em desenvolvimento ou `npx convex deploy`) para aplicar o suporte a múltiplos acessos remotos.",
},
{ status: 409 }
)
}
return NextResponse.json(
{
error: "Falha ao atualizar acesso remoto",
...(process.env.NODE_ENV !== "production" && detail ? { detail } : {}),
},
{ status: 500 }
)
} }
} }

View file

@ -34,7 +34,7 @@ import {
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
@ -238,7 +238,9 @@ type MachineInventory = {
collaborator?: { email?: string; name?: string; role?: string } collaborator?: { email?: string; name?: string; role?: string }
} }
type MachineRemoteAccess = { type MachineRemoteAccessEntry = {
id: string | null
clientId: string
provider: string | null provider: string | null
identifier: string | null identifier: string | null
url: string | null url: string | null
@ -290,15 +292,24 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
return undefined return undefined
} }
export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess | null { function createRemoteAccessClientId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
return `ra-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`
}
function normalizeMachineRemoteAccessEntry(raw: unknown): MachineRemoteAccessEntry | null {
if (!raw) return null if (!raw) return null
if (typeof raw === "string") { if (typeof raw === "string") {
const trimmed = raw.trim() const trimmed = raw.trim()
if (!trimmed) return null if (!trimmed) return null
const isUrl = /^https?:\/\//i.test(trimmed) const isUrl = /^https?:\/\//i.test(trimmed)
return { return {
id: null,
clientId: createRemoteAccessClientId(),
provider: null, provider: null,
identifier: isUrl ? null : trimmed, identifier: trimmed,
url: isUrl ? trimmed : null, url: isUrl ? trimmed : null,
notes: null, notes: null,
lastVerifiedAt: null, lastVerifiedAt: null,
@ -308,16 +319,21 @@ export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess
const record = toRecord(raw) const record = toRecord(raw)
if (!record) return null if (!record) return null
const provider = readString(record, "provider", "tool", "vendor", "name") ?? null const provider = readString(record, "provider", "tool", "vendor", "name") ?? null
const identifier = readString(record, "identifier", "code", "id", "accessId") ?? null const identifier =
readString(record, "identifier", "code", "id", "accessId") ??
readString(record, "value", "label")
const url = readString(record, "url", "link", "remoteUrl", "console", "viewer") ?? null const url = readString(record, "url", "link", "remoteUrl", "console", "viewer") ?? null
const notes = readString(record, "notes", "note", "description", "obs") ?? null const notes = readString(record, "notes", "note", "description", "obs") ?? null
const timestampCandidate = const timestampCandidate =
readNumber(record, "lastVerifiedAt", "verifiedAt", "checkedAt", "updatedAt") ?? readNumber(record, "lastVerifiedAt", "verifiedAt", "checkedAt", "updatedAt") ??
parseDateish(record["lastVerifiedAt"] ?? record["verifiedAt"] ?? record["checkedAt"] ?? record["updatedAt"]) parseDateish(record["lastVerifiedAt"] ?? record["verifiedAt"] ?? record["checkedAt"] ?? record["updatedAt"])
const lastVerifiedAt = timestampCandidate instanceof Date ? timestampCandidate.getTime() : timestampCandidate ?? null const lastVerifiedAt = timestampCandidate instanceof Date ? timestampCandidate.getTime() : timestampCandidate ?? null
const id = readString(record, "id") ?? null
return { return {
id,
clientId: id ?? createRemoteAccessClientId(),
provider, provider,
identifier, identifier: identifier ?? url ?? null,
url, url,
notes, notes,
lastVerifiedAt, lastVerifiedAt,
@ -325,6 +341,58 @@ export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess
} }
} }
export function normalizeMachineRemoteAccessList(raw: unknown): MachineRemoteAccessEntry[] {
if (!raw) return []
const source = Array.isArray(raw) ? raw : [raw]
const seen = new Set<string>()
const entries: MachineRemoteAccessEntry[] = []
for (const item of source) {
const entry = normalizeMachineRemoteAccessEntry(item)
if (!entry) continue
let clientId = entry.clientId
while (seen.has(clientId)) {
clientId = createRemoteAccessClientId()
}
seen.add(clientId)
entries.push(clientId === entry.clientId ? entry : { ...entry, clientId })
}
return entries
}
const REMOTE_ACCESS_METADATA_IGNORED_KEYS = new Set([
"provider",
"tool",
"vendor",
"name",
"identifier",
"code",
"id",
"accessId",
"url",
"link",
"remoteUrl",
"console",
"viewer",
"notes",
"note",
"description",
"obs",
"lastVerifiedAt",
"verifiedAt",
"checkedAt",
"updatedAt",
])
function extractRemoteAccessMetadataEntries(metadata: Record<string, unknown> | null | undefined) {
if (!metadata) return [] as Array<[string, unknown]>
return Object.entries(metadata).filter(([key, value]) => {
if (REMOTE_ACCESS_METADATA_IGNORED_KEYS.has(key)) return false
if (value === null || value === undefined) return false
if (typeof value === "string" && value.trim().length === 0) return false
return true
})
}
function formatRemoteAccessMetadataKey(key: string) { function formatRemoteAccessMetadataKey(key: string) {
return key return key
.replace(/[_.-]+/g, " ") .replace(/[_.-]+/g, " ")
@ -740,14 +808,14 @@ export type MachinesQueryItem = {
postureAlerts?: Array<Record<string, unknown>> | null postureAlerts?: Array<Record<string, unknown>> | null
lastPostureAt?: number | null lastPostureAt?: number | null
linkedUsers?: Array<{ id: string; email: string; name: string }> linkedUsers?: Array<{ id: string; email: string; name: string }>
remoteAccess: MachineRemoteAccess | null remoteAccessEntries: MachineRemoteAccessEntry[]
} }
export function normalizeMachineItem(raw: Record<string, unknown>): MachinesQueryItem { export function normalizeMachineItem(raw: Record<string, unknown>): MachinesQueryItem {
const base = raw as MachinesQueryItem const { remoteAccess, ...rest } = raw as Record<string, unknown> & { remoteAccess?: unknown }
return { return {
...base, ...(rest as MachinesQueryItem),
remoteAccess: normalizeMachineRemoteAccess(raw["remoteAccess"]) ?? null, remoteAccessEntries: normalizeMachineRemoteAccessList(remoteAccess),
} }
} }
@ -794,13 +862,13 @@ const TICKET_STATUS_LABELS: Record<string, string> = {
} }
const statusClasses: Record<string, string> = { const statusClasses: Record<string, string> = {
online: "border-emerald-500/20 bg-emerald-500/15 text-emerald-600", online: "border-emerald-200 text-emerald-600",
offline: "border-rose-500/20 bg-rose-500/15 text-rose-600", offline: "border-rose-200 text-rose-600",
stale: "border-slate-400/30 bg-slate-200 text-slate-700", stale: "border-amber-200 text-amber-600",
maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600", maintenance: "border-amber-300 text-amber-700",
blocked: "border-orange-500/20 bg-orange-500/15 text-orange-600", blocked: "border-orange-200 text-orange-600",
deactivated: "border-slate-400/40 bg-slate-100 text-slate-600", deactivated: "border-slate-200 bg-slate-50 text-slate-500",
unknown: "border-slate-300 bg-slate-200 text-slate-700", unknown: "border-slate-200 text-slate-600",
} }
const REMOTE_ACCESS_PROVIDERS = [ const REMOTE_ACCESS_PROVIDERS = [
@ -1257,6 +1325,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
? "bg-emerald-500" ? "bg-emerald-500"
: s === "offline" : s === "offline"
? "bg-rose-500" ? "bg-rose-500"
: s === "stale"
? "bg-amber-500"
: s === "maintenance" : s === "maintenance"
? "bg-amber-500" ? "bg-amber-500"
: s === "blocked" : s === "blocked"
@ -1269,6 +1339,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
? "bg-emerald-400/30" ? "bg-emerald-400/30"
: s === "offline" : s === "offline"
? "bg-rose-400/30" ? "bg-rose-400/30"
: s === "stale"
? "bg-amber-400/30"
: s === "maintenance" : s === "maintenance"
? "bg-amber-400/30" ? "bg-amber-400/30"
: s === "blocked" : s === "blocked"
@ -1279,11 +1351,22 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
const isOnline = s === "online" const isOnline = s === "online"
return ( return (
<Badge className={cn("inline-flex h-9 items-center gap-5 rounded-full border border-slate-200 px-3 text-sm font-semibold", className)}> <Badge
<span className="relative inline-flex items-center"> variant="outline"
<span className={cn("size-2 rounded-full", colorClass)} /> className={cn(
"inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-600 shadow-sm sm:text-sm",
className
)}
>
<span className="relative inline-flex size-4 items-center justify-center">
<span className={cn("size-2.5 rounded-full", colorClass)} />
{isOnline ? ( {isOnline ? (
<span className={cn("absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping [animation-duration:2s]", ringClass)} /> <span
className={cn(
"absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping [animation-duration:2s]",
ringClass
)}
/>
) : null} ) : null}
</span> </span>
{label} {label}
@ -1331,8 +1414,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const normalizedViewerRole = (viewerRole ?? "").toLowerCase() const normalizedViewerRole = (viewerRole ?? "").toLowerCase()
const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent" const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent"
const effectiveStatus = machine ? resolveMachineStatus(machine) : "unknown" const effectiveStatus = machine ? resolveMachineStatus(machine) : "unknown"
const isActive = machine?.isActive ?? true const [isActiveLocal, setIsActiveLocal] = useState<boolean>(machine?.isActive ?? true)
const isDeactivated = !isActive || effectiveStatus === "deactivated" const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
const alertsHistory = useQuery( const alertsHistory = useQuery(
machine ? api.machines.listAlerts : "skip", machine ? api.machines.listAlerts : "skip",
machine ? { machineId: machine.id as Id<"machines">, limit: 50 } : ("skip" as const) machine ? { machineId: machine.id as Id<"machines">, limit: 50 } : ("skip" as const)
@ -1763,54 +1846,33 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
return null return null
}, [machine?.assignedUserEmail, machine?.assignedUserName, machine?.persona, machine?.assignedUserRole, metadata]) }, [machine?.assignedUserEmail, machine?.assignedUserName, machine?.persona, machine?.assignedUserRole, metadata])
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador" const primaryLinkedUser: Collaborator | null = useMemo(() => {
const firstLinked = machine?.linkedUsers?.find((user) => typeof user?.email === "string" && user.email.trim().length > 0)
if (firstLinked) {
return {
email: firstLinked.email,
name: firstLinked.name ?? undefined,
role: collaborator?.role ?? machine?.persona ?? undefined,
}
}
if (collaborator?.email) {
return collaborator
}
if (machine?.authEmail) {
return {
email: machine.authEmail ?? undefined,
name: undefined,
role: machine?.persona ?? undefined,
}
}
return null
}, [collaborator, machine?.authEmail, machine?.linkedUsers, machine?.persona])
const remoteAccess = machine?.remoteAccess ?? null const personaRole = (primaryLinkedUser?.role ?? collaborator?.role ?? machine?.persona ?? "").toLowerCase()
const remoteAccessMetadataEntries = useMemo(() => { const personaLabel = personaRole === "manager" ? "Gestor" : "Colaborador"
if (!remoteAccess?.metadata) return [] as Array<[string, unknown]>
const knownKeys = new Set([ const remoteAccessEntries = useMemo(() => machine?.remoteAccessEntries ?? [], [machine?.remoteAccessEntries])
"provider", const hasRemoteAccess = remoteAccessEntries.length > 0
"tool",
"vendor",
"name",
"identifier",
"code",
"id",
"accessId",
"url",
"link",
"remoteUrl",
"console",
"viewer",
"notes",
"note",
"description",
"obs",
"lastVerifiedAt",
"verifiedAt",
"checkedAt",
"updatedAt",
])
return Object.entries(remoteAccess.metadata)
.filter(([key, value]) => {
if (knownKeys.has(key)) return false
if (value === null || value === undefined) return false
if (typeof value === "string" && value.trim().length === 0) return false
return true
})
}, [remoteAccess])
const remoteAccessLastVerifiedDate = useMemo(() => {
if (!remoteAccess?.lastVerifiedAt) return null
const date = new Date(remoteAccess.lastVerifiedAt)
return Number.isNaN(date.getTime()) ? null : date
}, [remoteAccess?.lastVerifiedAt])
const hasRemoteAccess = Boolean(
remoteAccess?.identifier ||
remoteAccess?.url ||
remoteAccess?.notes ||
remoteAccess?.provider ||
remoteAccessMetadataEntries.length > 0
)
const summaryChips = useMemo(() => { const summaryChips = useMemo(() => {
const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = [] const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = []
@ -1848,16 +1910,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
tone: windowsActivationStatus ? undefined : "warning", tone: windowsActivationStatus ? undefined : "warning",
}) })
} }
if (primaryGpu?.name) { if (primaryLinkedUser?.email) {
chips.push({ const collaboratorValue = primaryLinkedUser.name ? `${primaryLinkedUser.name} · ${primaryLinkedUser.email}` : primaryLinkedUser.email
key: "gpu",
label: "GPU principal",
value: `${primaryGpu.name}${typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""}`,
icon: <MemoryStick className="size-4 text-neutral-500" />,
})
}
if (collaborator?.email) {
const collaboratorValue = collaborator.name ? `${collaborator.name} · ${collaborator.email}` : collaborator.email
chips.push({ chips.push({
key: "collaborator", key: "collaborator",
label: personaLabel, label: personaLabel,
@ -1865,9 +1919,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
icon: <ShieldCheck className="size-4 text-neutral-500" />, icon: <ShieldCheck className="size-4 text-neutral-500" />,
}) })
} }
if (remoteAccess && (remoteAccess.identifier || remoteAccess.url)) { const primaryRemoteAccess = remoteAccessEntries[0]
const value = remoteAccess.identifier ?? remoteAccess.url ?? "—" if (primaryRemoteAccess && (primaryRemoteAccess.identifier || primaryRemoteAccess.url)) {
const label = remoteAccess.provider ? `Acesso (${remoteAccess.provider})` : "Acesso remoto" const value = primaryRemoteAccess.identifier ?? primaryRemoteAccess.url ?? "—"
const label = primaryRemoteAccess.provider ? `Acesso (${primaryRemoteAccess.provider})` : "Acesso remoto"
chips.push({ chips.push({
key: "remote-access", key: "remote-access",
label, label,
@ -1876,7 +1931,19 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}) })
} }
return chips return chips
}, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName, remoteAccess]) }, [
osNameDisplay,
machine?.osVersion,
machine?.architecture,
windowsVersionLabel,
windowsBuildLabel,
windowsActivationStatus,
primaryLinkedUser?.email,
primaryLinkedUser?.name,
personaLabel,
machine?.osName,
remoteAccessEntries,
])
const companyName = machine?.companyName ?? machine?.companySlug ?? null const companyName = machine?.companyName ?? machine?.companySlug ?? null
@ -1888,13 +1955,12 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
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 [accessDialog, setAccessDialog] = useState(false)
const [accessEmail, setAccessEmail] = useState<string>(collaborator?.email ?? "") const [accessEmail, setAccessEmail] = useState<string>(primaryLinkedUser?.email ?? "")
const [accessName, setAccessName] = useState<string>(collaborator?.name ?? "") const [accessName, setAccessName] = useState<string>(primaryLinkedUser?.name ?? "")
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">( const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(personaRole === "manager" ? "manager" : "collaborator")
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
)
const [savingAccess, setSavingAccess] = useState(false) const [savingAccess, setSavingAccess] = useState(false)
const [remoteAccessDialog, setRemoteAccessDialog] = useState(false) const [remoteAccessDialog, setRemoteAccessDialog] = useState(false)
const [editingRemoteAccessClientId, setEditingRemoteAccessClientId] = useState<string | null>(null)
const [remoteAccessProviderOption, setRemoteAccessProviderOption] = useState<RemoteAccessProviderValue>( const [remoteAccessProviderOption, setRemoteAccessProviderOption] = useState<RemoteAccessProviderValue>(
REMOTE_ACCESS_PROVIDERS[0].value, REMOTE_ACCESS_PROVIDERS[0].value,
) )
@ -1903,6 +1969,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const [remoteAccessUrlInput, setRemoteAccessUrlInput] = useState("") const [remoteAccessUrlInput, setRemoteAccessUrlInput] = useState("")
const [remoteAccessNotesInput, setRemoteAccessNotesInput] = useState("") const [remoteAccessNotesInput, setRemoteAccessNotesInput] = useState("")
const [remoteAccessSaving, setRemoteAccessSaving] = useState(false) const [remoteAccessSaving, setRemoteAccessSaving] = useState(false)
const editingRemoteAccess = useMemo(
() => remoteAccessEntries.find((entry) => entry.clientId === editingRemoteAccessClientId) ?? null,
[editingRemoteAccessClientId, remoteAccessEntries]
)
const [togglingActive, setTogglingActive] = useState(false) const [togglingActive, setTogglingActive] = useState(false)
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false) const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
const jsonText = useMemo(() => { const jsonText = useMemo(() => {
@ -1942,14 +2012,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
// removed copy/export inventory JSON buttons as requested // removed copy/export inventory JSON buttons as requested
useEffect(() => { useEffect(() => {
setAccessEmail(collaborator?.email ?? "") setAccessEmail(primaryLinkedUser?.email ?? "")
setAccessName(collaborator?.name ?? "") setAccessName(primaryLinkedUser?.name ?? "")
setAccessRole((machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator") setAccessRole(personaRole === "manager" ? "manager" : "collaborator")
}, [machine?.id, machine?.persona, collaborator?.email, collaborator?.name, collaborator?.role]) }, [machine?.id, primaryLinkedUser?.email, primaryLinkedUser?.name, personaRole])
useEffect(() => {
setIsActiveLocal(machine?.isActive ?? true)
}, [machine?.isActive])
useEffect(() => { useEffect(() => {
if (!remoteAccessDialog) return if (!remoteAccessDialog) return
const providerName = remoteAccess?.provider ?? "" const providerName = editingRemoteAccess?.provider ?? ""
const matched = REMOTE_ACCESS_PROVIDERS.find( const matched = REMOTE_ACCESS_PROVIDERS.find(
(option) => option.value !== "OTHER" && option.label.toLowerCase() === providerName.toLowerCase(), (option) => option.value !== "OTHER" && option.label.toLowerCase() === providerName.toLowerCase(),
) )
@ -1960,21 +2034,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
setRemoteAccessProviderOption(providerName ? "OTHER" : REMOTE_ACCESS_PROVIDERS[0].value) setRemoteAccessProviderOption(providerName ? "OTHER" : REMOTE_ACCESS_PROVIDERS[0].value)
setRemoteAccessCustomProvider(providerName ?? "") setRemoteAccessCustomProvider(providerName ?? "")
} }
setRemoteAccessIdentifierInput(remoteAccess?.identifier ?? "") setRemoteAccessIdentifierInput(editingRemoteAccess?.identifier ?? "")
setRemoteAccessUrlInput(remoteAccess?.url ?? "") setRemoteAccessUrlInput(editingRemoteAccess?.url ?? "")
setRemoteAccessNotesInput(remoteAccess?.notes ?? "") setRemoteAccessNotesInput(editingRemoteAccess?.notes ?? "")
}, [remoteAccessDialog, remoteAccess]) }, [remoteAccessDialog, editingRemoteAccess])
useEffect(() => { useEffect(() => {
if (remoteAccessDialog) return if (remoteAccessDialog) return
if (!remoteAccess) { if (!editingRemoteAccessClientId) {
setRemoteAccessProviderOption(REMOTE_ACCESS_PROVIDERS[0].value) setRemoteAccessProviderOption(REMOTE_ACCESS_PROVIDERS[0].value)
setRemoteAccessCustomProvider("") setRemoteAccessCustomProvider("")
setRemoteAccessIdentifierInput("") setRemoteAccessIdentifierInput("")
setRemoteAccessUrlInput("") setRemoteAccessUrlInput("")
setRemoteAccessNotesInput("") setRemoteAccessNotesInput("")
} }
}, [remoteAccess, remoteAccessDialog]) }, [editingRemoteAccessClientId, remoteAccessDialog])
useEffect(() => { useEffect(() => {
setShowAllWindowsSoftware(false) setShowAllWindowsSoftware(false)
@ -2060,6 +2134,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
setRemoteAccessSaving(true) setRemoteAccessSaving(true)
try { try {
if (editingRemoteAccess && !editingRemoteAccess.id) {
const cleanupPayload: Record<string, unknown> = {
machineId: machine.id,
action: "delete",
}
if (editingRemoteAccess.provider) cleanupPayload.provider = editingRemoteAccess.provider
if (editingRemoteAccess.identifier) cleanupPayload.identifier = editingRemoteAccess.identifier
if (editingRemoteAccess.clientId) cleanupPayload.entryId = editingRemoteAccess.clientId
await fetch("/api/admin/machines/remote-access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cleanupPayload),
}).catch(() => null)
}
const response = await fetch("/api/admin/machines/remote-access", { const response = await fetch("/api/admin/machines/remote-access", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -2069,18 +2158,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
identifier, identifier,
url: normalizedUrl, url: normalizedUrl,
notes: notes.length ? notes : undefined, notes: notes.length ? notes : undefined,
action: "save", action: "upsert",
entryId: editingRemoteAccess?.id ?? undefined,
}), }),
}) })
const payload = await response.json().catch(() => null) const responsePayload = await response.json().catch(() => null)
if (!response.ok) { if (!response.ok) {
const message = typeof payload?.error === "string" ? payload.error : "Falha ao atualizar acesso remoto." const message = typeof responsePayload?.error === "string" ? responsePayload.error : "Falha ao atualizar acesso remoto."
throw new Error(message) const detailMessage = typeof responsePayload?.detail === "string" ? responsePayload.detail : null
throw new Error(detailMessage ? `${message}. ${detailMessage}` : message)
} }
toast.success("Acesso remoto atualizado.", { id: "remote-access" }) toast.success("Acesso remoto atualizado.", { id: "remote-access" })
setRemoteAccessDialog(false) setRemoteAccessDialog(false)
setEditingRemoteAccessClientId(null)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Falha ao atualizar acesso remoto." const message = error instanceof Error ? error.message : "Falha ao atualizar acesso remoto."
toast.error(message, { id: "remote-access" }) toast.error(message, { id: "remote-access" })
@ -2095,9 +2187,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
remoteAccessIdentifierInput, remoteAccessIdentifierInput,
remoteAccessUrlInput, remoteAccessUrlInput,
remoteAccessNotesInput, remoteAccessNotesInput,
editingRemoteAccess,
]) ])
const handleRemoveRemoteAccess = useCallback(async () => { const handleRemoveRemoteAccess = useCallback(async (entry: MachineRemoteAccessEntry) => {
if (!machine) return if (!machine) return
if (!canManageRemoteAccess) { if (!canManageRemoteAccess) {
toast.error("Você não tem permissão para ajustar o acesso remoto desta máquina.") toast.error("Você não tem permissão para ajustar o acesso remoto desta máquina.")
@ -2107,21 +2200,35 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
toast.loading("Removendo acesso remoto...", { id: "remote-access" }) toast.loading("Removendo acesso remoto...", { id: "remote-access" })
setRemoteAccessSaving(true) setRemoteAccessSaving(true)
try { try {
const requestPayload: Record<string, unknown> = {
machineId: machine.id,
action: "delete",
}
if (entry.id) {
requestPayload.entryId = entry.id
} else if (entry.clientId) {
requestPayload.entryId = entry.clientId
}
if (entry.provider) {
requestPayload.provider = entry.provider
}
if (entry.identifier) {
requestPayload.identifier = entry.identifier
}
const response = await fetch("/api/admin/machines/remote-access", { const response = await fetch("/api/admin/machines/remote-access", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify(requestPayload),
machineId: machine.id,
action: "clear",
}),
}) })
const payload = await response.json().catch(() => null) const responsePayload = await response.json().catch(() => null)
if (!response.ok) { if (!response.ok) {
const message = typeof payload?.error === "string" ? payload.error : "Falha ao remover acesso remoto." const message = typeof responsePayload?.error === "string" ? responsePayload.error : "Falha ao remover acesso remoto."
throw new Error(message) const detailMessage = typeof responsePayload?.detail === "string" ? responsePayload.detail : null
throw new Error(detailMessage ? `${message}. ${detailMessage}` : message)
} }
toast.success("Acesso remoto removido.", { id: "remote-access" }) toast.success("Acesso remoto removido.", { id: "remote-access" })
setRemoteAccessDialog(false) setRemoteAccessDialog(false)
setEditingRemoteAccessClientId(null)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Falha ao remover acesso remoto." const message = error instanceof Error ? error.message : "Falha ao remover acesso remoto."
toast.error(message, { id: "remote-access" }) toast.error(message, { id: "remote-access" })
@ -2132,37 +2239,40 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const handleToggleActive = async () => { const handleToggleActive = async () => {
if (!machine) return if (!machine) return
const nextActive = !isActiveLocal
setIsActiveLocal(nextActive)
setTogglingActive(true) setTogglingActive(true)
try { try {
const response = await fetch("/api/admin/machines/toggle-active", { const response = await fetch("/api/admin/machines/toggle-active", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: machine.id, active: !isActive }), body: JSON.stringify({ machineId: machine.id, active: nextActive }),
credentials: "include", credentials: "include",
}) })
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string } const payload = (await response.json().catch(() => ({}))) as { error?: string }
throw new Error(payload?.error ?? "Falha ao atualizar status") throw new Error(payload?.error ?? "Falha ao atualizar status")
} }
toast.success(!isActive ? "Máquina reativada" : "Máquina desativada") toast.success(nextActive ? "Máquina reativada" : "Máquina desativada")
} catch (error) { } catch (error) {
console.error(error) console.error(error)
setIsActiveLocal(!nextActive)
toast.error("Não foi possível atualizar o status da máquina.") toast.error("Não foi possível atualizar o status da máquina.")
} finally { } finally {
setTogglingActive(false) setTogglingActive(false)
} }
} }
const handleCopyRemoteIdentifier = useCallback(async () => { const handleCopyRemoteIdentifier = useCallback(async (identifier: string | null | undefined) => {
if (!remoteAccess?.identifier) return if (!identifier) return
try { try {
await navigator.clipboard.writeText(remoteAccess.identifier) await navigator.clipboard.writeText(identifier)
toast.success("Identificador de acesso remoto copiado.") toast.success("Identificador de acesso remoto copiado.")
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.error("Não foi possível copiar o identificador.") toast.error("Não foi possível copiar o identificador.")
} }
}, [remoteAccess?.identifier]) }, [])
return ( return (
<Card className="border-slate-200"> <Card className="border-slate-200">
@ -2178,7 +2288,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</div> </div>
) : null} ) : null}
{!isDeactivated ? <MachineStatusBadge status={effectiveStatus} /> : null} {!isDeactivated ? <MachineStatusBadge status={effectiveStatus} /> : null}
{!isActive ? ( {!isActiveLocal ? (
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700"> <Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
Máquina desativada Máquina desativada
</Badge> </Badge>
@ -2213,42 +2323,48 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} /> <InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} />
))} ))}
</div> </div>
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/40 px-4 py-4"> <div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-center">
<h4 className="text-sm font-semibold text-indigo-900">Tickets abertos por esta máquina</h4> <div className="space-y-1">
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold uppercase tracking-wide text-indigo-700"> <h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
{machineTickets.length} {machineTickets.length === 0 ? (
</Badge> <p className="text-xs text-[color:var(--accent-foreground)]/80">Nenhum chamado em aberto registrado diretamente por esta máquina.</p>
) : (
<ul className="space-y-2">
{machineTickets.map((ticket) => (
<li
key={ticket.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white px-3 py-2 text-sm shadow-sm"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-slate-200 text-[11px] uppercase text-neutral-600">
{ticket.priority}
</Badge>
<Badge className="bg-indigo-600 text-[11px] uppercase tracking-wide text-white">
{TICKET_STATUS_LABELS[ticket.status] ?? ticket.status}
</Badge>
</div>
</li>
))}
</ul>
)}
</div>
<div className="self-center justify-self-end">
<div className="flex h-12 min-w-[72px] items-center justify-center rounded-2xl border border-[color:var(--accent)] bg-white px-5 shadow-sm sm:min-w-[88px]">
<span className="text-2xl font-semibold leading-none text-accent-foreground tabular-nums sm:text-3xl">
{machineTickets.length}
</span>
</div>
</div>
</div> </div>
{machineTickets.length === 0 ? (
<p className="mt-3 text-xs text-indigo-700">Nenhum chamado em aberto registrado diretamente por esta máquina.</p>
) : (
<ul className="mt-3 space-y-2">
{machineTickets.map((ticket) => (
<li
key={ticket.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-indigo-200 bg-white px-3 py-2 text-sm shadow-sm"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-slate-200 text-[11px] uppercase text-neutral-600">
{ticket.priority}
</Badge>
<Badge className="bg-indigo-600 text-[11px] uppercase tracking-wide text-white">
{TICKET_STATUS_LABELS[ticket.status] ?? ticket.status}
</Badge>
</div>
</li>
))}
</ul>
)}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{machine.authEmail ? ( {machine.authEmail ? (
@ -2263,30 +2379,37 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant={isActive ? "outline" : "default"} variant={isActiveLocal ? "outline" : "default"}
className={cn( className={cn(
"gap-2 border-dashed", "gap-2 border-dashed",
!isActive && "bg-emerald-600 text-white hover:bg-emerald-600/90" !isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
)} )}
onClick={handleToggleActive} onClick={handleToggleActive}
disabled={togglingActive} disabled={togglingActive}
> >
{isActive ? <Power className="size-4" /> : <PlayCircle className="size-4" />} {isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
{isActive ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"} {isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
</Button> </Button>
{machine.registeredBy ? ( {machine.registeredBy ? (
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700"> <span
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-2 border-dashed border-slate-200 bg-background cursor-default select-text text-neutral-700 hover:bg-background hover:text-neutral-700 focus-visible:outline-none"
)}
>
Registrada via {machine.registeredBy} Registrada via {machine.registeredBy}
</Badge> </span>
) : null} ) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Acesso remoto</h4> <h4 className="text-sm font-semibold">Acesso remoto</h4>
{remoteAccess?.provider ? ( {hasRemoteAccess ? (
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold text-indigo-700"> <Badge variant="outline" className="border-slate-200 bg-slate-100 text-[11px] font-semibold text-slate-700">
{remoteAccess.provider} {remoteAccessEntries.length === 1
? remoteAccessEntries[0].provider ?? "Configuração única"
: `${remoteAccessEntries.length} acessos`}
</Badge> </Badge>
) : null} ) : null}
</div> </div>
@ -2295,59 +2418,114 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
size="sm" size="sm"
variant="outline" variant="outline"
className="gap-2 border-dashed" className="gap-2 border-dashed"
onClick={() => setRemoteAccessDialog(true)} onClick={() => {
setEditingRemoteAccessClientId(null)
setRemoteAccessDialog(true)
}}
> >
<Key className="size-4" /> <Key className="size-4" />
{hasRemoteAccess ? "Editar acesso" : "Adicionar acesso"} {hasRemoteAccess ? "Adicionar acesso" : "Cadastrar acesso"}
</Button> </Button>
) : null} ) : null}
</div> </div>
{hasRemoteAccess ? ( {hasRemoteAccess ? (
<div className="rounded-lg border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-xs sm:text-sm text-slate-700"> <div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> {remoteAccessEntries.map((entry) => {
<div className="space-y-2"> const metadataEntries = extractRemoteAccessMetadataEntries(entry.metadata)
{remoteAccess?.identifier ? ( const lastVerifiedDate =
<div className="flex flex-wrap items-center gap-2"> entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
<span className="font-semibold text-slate-900">{remoteAccess.identifier}</span> ? new Date(entry.lastVerifiedAt)
<Button variant="ghost" size="sm" className="h-7 gap-2 px-2" onClick={handleCopyRemoteIdentifier}> : null
<ClipboardCopy className="size-3.5" /> Copiar ID return (
</Button> <div key={entry.clientId} className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-700">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{entry.provider ? (
<Badge variant="outline" className="border-slate-200 bg-white text-[11px] font-semibold text-slate-700">
{entry.provider}
</Badge>
) : null}
{entry.identifier ? (
<span className="font-semibold text-neutral-800">{entry.identifier}</span>
) : null}
{entry.identifier ? (
<Button
variant="ghost"
size="sm"
className="h-7 gap-2 border border-transparent px-2 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteIdentifier(entry.identifier)}
>
<ClipboardCopy className="size-3.5" /> Copiar ID
</Button>
) : null}
</div>
{entry.url ? (
<a
href={entry.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-slate-600 underline-offset-4 hover:text-slate-900 hover:underline"
>
Abrir console remoto
</a>
) : null}
{entry.notes ? (
<p className="whitespace-pre-wrap text-[11px] text-slate-600">{entry.notes}</p>
) : null}
{lastVerifiedDate ? (
<p className="text-[11px] text-slate-500">
Atualizado {formatRelativeTime(lastVerifiedDate)}{" "}
<span className="text-slate-400">({formatAbsoluteDateTime(lastVerifiedDate)})</span>
</p>
) : null}
</div>
{canManageRemoteAccess ? (
<div className="flex items-start gap-2">
<Button
size="sm"
variant="outline"
className="gap-2 border-slate-300"
onClick={() => {
setEditingRemoteAccessClientId(entry.clientId)
setRemoteAccessDialog(true)
}}
>
<Pencil className="size-3.5" /> Editar
</Button>
<Button
size="sm"
variant="ghost"
className="gap-2 text-rose-600 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700"
onClick={() => void handleRemoveRemoteAccess(entry)}
disabled={remoteAccessSaving}
>
<ShieldOff className="size-3.5" /> Remover
</Button>
</div>
) : null}
</div> </div>
) : null} {metadataEntries.length ? (
{remoteAccess?.url ? ( <details className="mt-3 rounded-lg border border-slate-200 bg-white/70 px-3 py-2 text-[11px] text-slate-600">
<a <summary className="cursor-pointer font-semibold text-slate-700 outline-none transition-colors hover:text-slate-900">
href={remoteAccess.url} Metadados adicionais
target="_blank" </summary>
rel="noreferrer" <div className="mt-2 grid gap-2 sm:grid-cols-2">
className="inline-flex items-center gap-2 text-indigo-700 underline-offset-4 hover:underline" {metadataEntries.map(([key, value]) => (
> <div key={`${entry.clientId}-${key}`} className="flex items-center justify-between gap-3 rounded-md border border-slate-200 bg-white px-2 py-1 shadow-sm">
Abrir console remoto <span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
</a> <span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
) : null} </div>
{remoteAccess?.notes ? ( ))}
<p className="whitespace-pre-wrap text-[11px] text-slate-600">{remoteAccess.notes}</p> </div>
) : null} </details>
{remoteAccessLastVerifiedDate ? ( ) : null}
<p className="text-[11px] text-slate-500"> </div>
Atualizado {formatRelativeTime(remoteAccessLastVerifiedDate)}{" "} )
<span className="text-slate-400">({formatAbsoluteDateTime(remoteAccessLastVerifiedDate)})</span> })}
</p>
) : null}
</div>
</div>
{remoteAccessMetadataEntries.length ? (
<div className="mt-3 grid gap-2 text-[11px] text-slate-600 sm:grid-cols-2">
{remoteAccessMetadataEntries.map(([key, value]) => (
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-indigo-100 bg-white px-2 py-1">
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
</div>
))}
</div>
) : null}
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-dashed border-indigo-200 bg-indigo-50/40 px-4 py-3 text-xs sm:text-sm text-slate-600"> <div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-600">
Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte. Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte.
</div> </div>
)} )}
@ -2357,11 +2535,11 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<section className="space-y-2"> <section className="space-y-2">
<h4 className="text-sm font-semibold">Usuários vinculados</h4> <h4 className="text-sm font-semibold">Usuários vinculados</h4>
<div className="space-y-2"> <div className="space-y-2">
{collaborator?.email ? ( {primaryLinkedUser?.email ? (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-2 text-sm"> <div className="flex flex-wrap items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-2 text-sm">
<ShieldCheck className="size-4 text-slate-500" /> <ShieldCheck className="size-4 text-slate-500" />
<span className="font-medium text-neutral-800">{collaborator.name || collaborator.email}</span> <span className="font-medium text-neutral-800">{primaryLinkedUser.name || primaryLinkedUser.email}</span>
<span className="text-neutral-500">{collaborator.name ? `· ${collaborator.email}` : ''}</span> <span className="text-neutral-500">{primaryLinkedUser.name ? `· ${primaryLinkedUser.email}` : ""}</span>
<span className="ml-2 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-600">Principal</span> <span className="ml-2 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-600">Principal</span>
</div> </div>
) : null} ) : null}
@ -2394,7 +2572,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
))} ))}
</ul> </ul>
) : null} ) : null}
{!collaborator?.email && (!machine.linkedUsers || machine.linkedUsers.length === 0) ? ( {!primaryLinkedUser?.email && (!machine.linkedUsers || machine.linkedUsers.length === 0) ? (
<p className="text-xs text-neutral-500">Nenhum usuário vinculado.</p> <p className="text-xs text-neutral-500">Nenhum usuário vinculado.</p>
) : null} ) : null}
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
@ -2431,7 +2609,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
Adicionar vínculo Adicionar vínculo
</Button> </Button>
<span className="text-xs text-neutral-500">Somente colaboradores/gestores.</span> <span className="text-xs text-neutral-500">Somente colaboradores/gestores.</span>
<Link href="/admin" className="text-xs underline underline-offset-4">Gerenciar usuários</Link> <Link href="/admin/users" className="text-xs underline underline-offset-4">Gerenciar usuários</Link>
</div> </div>
</div> </div>
</section> </section>
@ -2518,14 +2696,15 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
setRemoteAccessDialog(open) setRemoteAccessDialog(open)
if (!open) { if (!open) {
setRemoteAccessSaving(false) setRemoteAccessSaving(false)
setEditingRemoteAccessClientId(null)
} }
}} }}
> >
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Detalhes de acesso remoto</DialogTitle> <DialogTitle>{editingRemoteAccess ? "Editar acesso remoto" : "Adicionar acesso remoto"}</DialogTitle>
<DialogDescription> <DialogDescription>
Registre o provedor e o identificador utilizado para acesso remoto à máquina. Registre os detalhes do acesso remoto utilizado por esta máquina.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
@ -2591,12 +2770,12 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
/> />
</div> </div>
<DialogFooter className="flex flex-wrap items-center justify-between gap-2"> <DialogFooter className="flex flex-wrap items-center justify-between gap-2">
{hasRemoteAccess ? ( {editingRemoteAccess ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700" className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
onClick={handleRemoveRemoteAccess} onClick={() => editingRemoteAccess && void handleRemoveRemoteAccess(editingRemoteAccess)}
disabled={remoteAccessSaving} disabled={remoteAccessSaving}
> >
Remover acesso Remover acesso
@ -2614,7 +2793,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
Cancelar Cancelar
</Button> </Button>
<Button type="submit" disabled={remoteAccessSaving}> <Button type="submit" disabled={remoteAccessSaving}>
{remoteAccessSaving ? "Salvando..." : "Salvar"} {remoteAccessSaving ? "Salvando..." : editingRemoteAccess ? "Atualizar" : "Salvar"}
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>
@ -3894,7 +4073,7 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
<span className="rounded-full border border-slate-300 bg-slate-100 px-2 py-0.5"> <span className="rounded-full border border-slate-300 bg-slate-100 px-2 py-0.5">
Usuário vinculado: {collaborator.name ? `${collaborator.name} · ` : ""}{collaborator.email} Usuário vinculado: {collaborator.name ? `${collaborator.name} · ` : ""}{collaborator.email}
</span> </span>
<Link href="/admin" className="underline underline-offset-4">Gerenciar usuários</Link> <Link href="/admin/users" className="underline underline-offset-4">Gerenciar usuários</Link>
</div> </div>
) : null} ) : null}
{!isActive ? ( {!isActive ? (