feat: melhorar gerenciamento de acesso remoto de máquinas
This commit is contained in:
parent
714b199879
commit
192a5c2909
5 changed files with 664 additions and 261 deletions
|
|
@ -18,6 +18,10 @@ export const ensureDefaults = mutation({
|
|||
await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" });
|
||||
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;
|
||||
})
|
||||
);
|
||||
|
|
@ -25,7 +29,7 @@ export const ensureDefaults = mutation({
|
|||
const queues = [
|
||||
{ name: "Chamados", slug: "chamados" },
|
||||
{ name: "Laboratório", slug: "laboratorio" },
|
||||
{ name: "Field Services", slug: "field-services" },
|
||||
{ name: "Visitas", slug: "visitas" },
|
||||
];
|
||||
for (const q of queues) {
|
||||
await ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined });
|
||||
|
|
@ -33,4 +37,3 @@ export const ensureDefaults = mutation({
|
|||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
|
|
@ -1375,9 +1487,11 @@ export const updateRemoteAccess = mutation({
|
|||
identifier: v.optional(v.string()),
|
||||
url: v.optional(v.string()),
|
||||
notes: v.optional(v.string()),
|
||||
action: v.optional(v.string()),
|
||||
entryId: v.optional(v.string()),
|
||||
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)
|
||||
if (!machine) {
|
||||
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.")
|
||||
}
|
||||
|
||||
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() })
|
||||
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 trimmedIdentifier = (identifier ?? "").trim()
|
||||
if (!trimmedProvider || !trimmedIdentifier) {
|
||||
|
|
@ -1422,8 +1574,15 @@ export const updateRemoteAccess = mutation({
|
|||
|
||||
const cleanedNotes = notes?.trim() ? notes.trim() : null
|
||||
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,
|
||||
identifier: trimmedIdentifier,
|
||||
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, {
|
||||
remoteAccess,
|
||||
remoteAccess: nextEntries,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
return { remoteAccess }
|
||||
return { remoteAccess: nextEntries }
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcru
|
|||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default function AdminMachineDetailsPage({ params }: { params: { id: string } }) {
|
||||
const { id } = params
|
||||
export default async function AdminMachineDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
return (
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ConvexHttpClient } from "convex/browser"
|
|||
|
||||
import { assertStaffSession } from "@/lib/auth-server"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
|
|
@ -13,7 +14,8 @@ const schema = z.object({
|
|||
identifier: z.string().optional(),
|
||||
url: 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) {
|
||||
|
|
@ -21,9 +23,6 @@ export async function POST(request: Request) {
|
|||
if (!session) {
|
||||
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>
|
||||
try {
|
||||
|
|
@ -41,24 +40,44 @@ export async function POST(request: Request) {
|
|||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
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 result = (await (client as unknown as { mutation: (name: string, args: Record<string, unknown>) => Promise<unknown> }).mutation("machines:updateRemoteAccess", {
|
||||
machineId: parsed.machineId as Id<"machines">,
|
||||
actorId: session.user.id as Id<"users">,
|
||||
clear: true,
|
||||
})) as { remoteAccess?: unknown } | null
|
||||
return NextResponse.json({ ok: true, remoteAccess: result?.remoteAccess ?? null })
|
||||
const actorId = ensured?._id as Id<"users"> | undefined
|
||||
if (!actorId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Usuário não encontrado no Convex para executar esta ação." },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const actionRaw = (parsed.action ?? "save").toLowerCase()
|
||||
const normalizedAction =
|
||||
actionRaw === "clear"
|
||||
? "clear"
|
||||
: actionRaw === "delete" || actionRaw === "remove"
|
||||
? "delete"
|
||||
: "upsert"
|
||||
|
||||
const provider = (parsed.provider ?? "").trim()
|
||||
const identifier = (parsed.identifier ?? "").trim()
|
||||
const notes = (parsed.notes ?? "").trim()
|
||||
|
||||
let normalizedUrl: string | undefined
|
||||
if (normalizedAction === "upsert") {
|
||||
if (!provider || !identifier) {
|
||||
return NextResponse.json({ error: "Informe o provedor e o identificador do acesso remoto." }, { status: 400 })
|
||||
}
|
||||
|
||||
let normalizedUrl: string | undefined
|
||||
const rawUrl = (parsed.url ?? "").trim()
|
||||
if (rawUrl.length > 0) {
|
||||
const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`
|
||||
|
|
@ -69,21 +88,55 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ error: "URL inválida. Informe um endereço iniciado com http:// ou https://." }, { status: 422 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notes = (parsed.notes ?? "").trim()
|
||||
|
||||
const result = (await (client as unknown as { mutation: (name: string, args: Record<string, unknown>) => Promise<unknown> }).mutation("machines:updateRemoteAccess", {
|
||||
const mutationArgs: Record<string, unknown> = {
|
||||
machineId: parsed.machineId as Id<"machines">,
|
||||
actorId: session.user.id as Id<"users">,
|
||||
provider,
|
||||
identifier,
|
||||
url: normalizedUrl,
|
||||
notes: notes.length ? notes : undefined,
|
||||
})) as { remoteAccess?: unknown } | null
|
||||
actorId,
|
||||
action: normalizedAction,
|
||||
}
|
||||
|
||||
if (parsed.entryId) {
|
||||
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 })
|
||||
} catch (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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
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 { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
|
@ -238,7 +238,9 @@ type MachineInventory = {
|
|||
collaborator?: { email?: string; name?: string; role?: string }
|
||||
}
|
||||
|
||||
type MachineRemoteAccess = {
|
||||
type MachineRemoteAccessEntry = {
|
||||
id: string | null
|
||||
clientId: string
|
||||
provider: string | null
|
||||
identifier: string | null
|
||||
url: string | null
|
||||
|
|
@ -290,15 +292,24 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
|
|||
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 (typeof raw === "string") {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return null
|
||||
const isUrl = /^https?:\/\//i.test(trimmed)
|
||||
return {
|
||||
id: null,
|
||||
clientId: createRemoteAccessClientId(),
|
||||
provider: null,
|
||||
identifier: isUrl ? null : trimmed,
|
||||
identifier: trimmed,
|
||||
url: isUrl ? trimmed : null,
|
||||
notes: null,
|
||||
lastVerifiedAt: null,
|
||||
|
|
@ -308,16 +319,21 @@ export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess
|
|||
const record = toRecord(raw)
|
||||
if (!record) return 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 notes = readString(record, "notes", "note", "description", "obs") ?? null
|
||||
const timestampCandidate =
|
||||
readNumber(record, "lastVerifiedAt", "verifiedAt", "checkedAt", "updatedAt") ??
|
||||
parseDateish(record["lastVerifiedAt"] ?? record["verifiedAt"] ?? record["checkedAt"] ?? record["updatedAt"])
|
||||
const lastVerifiedAt = timestampCandidate instanceof Date ? timestampCandidate.getTime() : timestampCandidate ?? null
|
||||
const id = readString(record, "id") ?? null
|
||||
return {
|
||||
id,
|
||||
clientId: id ?? createRemoteAccessClientId(),
|
||||
provider,
|
||||
identifier,
|
||||
identifier: identifier ?? url ?? null,
|
||||
url,
|
||||
notes,
|
||||
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) {
|
||||
return key
|
||||
.replace(/[_.-]+/g, " ")
|
||||
|
|
@ -740,14 +808,14 @@ export type MachinesQueryItem = {
|
|||
postureAlerts?: Array<Record<string, unknown>> | null
|
||||
lastPostureAt?: number | null
|
||||
linkedUsers?: Array<{ id: string; email: string; name: string }>
|
||||
remoteAccess: MachineRemoteAccess | null
|
||||
remoteAccessEntries: MachineRemoteAccessEntry[]
|
||||
}
|
||||
|
||||
export function normalizeMachineItem(raw: Record<string, unknown>): MachinesQueryItem {
|
||||
const base = raw as MachinesQueryItem
|
||||
const { remoteAccess, ...rest } = raw as Record<string, unknown> & { remoteAccess?: unknown }
|
||||
return {
|
||||
...base,
|
||||
remoteAccess: normalizeMachineRemoteAccess(raw["remoteAccess"]) ?? null,
|
||||
...(rest as MachinesQueryItem),
|
||||
remoteAccessEntries: normalizeMachineRemoteAccessList(remoteAccess),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -794,13 +862,13 @@ const TICKET_STATUS_LABELS: Record<string, string> = {
|
|||
}
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
online: "border-emerald-500/20 bg-emerald-500/15 text-emerald-600",
|
||||
offline: "border-rose-500/20 bg-rose-500/15 text-rose-600",
|
||||
stale: "border-slate-400/30 bg-slate-200 text-slate-700",
|
||||
maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600",
|
||||
blocked: "border-orange-500/20 bg-orange-500/15 text-orange-600",
|
||||
deactivated: "border-slate-400/40 bg-slate-100 text-slate-600",
|
||||
unknown: "border-slate-300 bg-slate-200 text-slate-700",
|
||||
online: "border-emerald-200 text-emerald-600",
|
||||
offline: "border-rose-200 text-rose-600",
|
||||
stale: "border-amber-200 text-amber-600",
|
||||
maintenance: "border-amber-300 text-amber-700",
|
||||
blocked: "border-orange-200 text-orange-600",
|
||||
deactivated: "border-slate-200 bg-slate-50 text-slate-500",
|
||||
unknown: "border-slate-200 text-slate-600",
|
||||
}
|
||||
|
||||
const REMOTE_ACCESS_PROVIDERS = [
|
||||
|
|
@ -1257,6 +1325,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
|
|||
? "bg-emerald-500"
|
||||
: s === "offline"
|
||||
? "bg-rose-500"
|
||||
: s === "stale"
|
||||
? "bg-amber-500"
|
||||
: s === "maintenance"
|
||||
? "bg-amber-500"
|
||||
: s === "blocked"
|
||||
|
|
@ -1269,6 +1339,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
|
|||
? "bg-emerald-400/30"
|
||||
: s === "offline"
|
||||
? "bg-rose-400/30"
|
||||
: s === "stale"
|
||||
? "bg-amber-400/30"
|
||||
: s === "maintenance"
|
||||
? "bg-amber-400/30"
|
||||
: s === "blocked"
|
||||
|
|
@ -1279,11 +1351,22 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
|
|||
|
||||
const isOnline = s === "online"
|
||||
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)}>
|
||||
<span className="relative inline-flex items-center">
|
||||
<span className={cn("size-2 rounded-full", colorClass)} />
|
||||
<Badge
|
||||
variant="outline"
|
||||
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 ? (
|
||||
<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}
|
||||
</span>
|
||||
{label}
|
||||
|
|
@ -1331,8 +1414,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
const normalizedViewerRole = (viewerRole ?? "").toLowerCase()
|
||||
const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent"
|
||||
const effectiveStatus = machine ? resolveMachineStatus(machine) : "unknown"
|
||||
const isActive = machine?.isActive ?? true
|
||||
const isDeactivated = !isActive || effectiveStatus === "deactivated"
|
||||
const [isActiveLocal, setIsActiveLocal] = useState<boolean>(machine?.isActive ?? true)
|
||||
const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
|
||||
const alertsHistory = useQuery(
|
||||
machine ? api.machines.listAlerts : "skip",
|
||||
machine ? { machineId: machine.id as Id<"machines">, limit: 50 } : ("skip" as const)
|
||||
|
|
@ -1763,54 +1846,33 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
return null
|
||||
}, [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 remoteAccessMetadataEntries = useMemo(() => {
|
||||
if (!remoteAccess?.metadata) return [] as Array<[string, unknown]>
|
||||
const knownKeys = new Set([
|
||||
"provider",
|
||||
"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 personaRole = (primaryLinkedUser?.role ?? collaborator?.role ?? machine?.persona ?? "").toLowerCase()
|
||||
const personaLabel = personaRole === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const remoteAccessEntries = useMemo(() => machine?.remoteAccessEntries ?? [], [machine?.remoteAccessEntries])
|
||||
const hasRemoteAccess = remoteAccessEntries.length > 0
|
||||
|
||||
const summaryChips = useMemo(() => {
|
||||
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",
|
||||
})
|
||||
}
|
||||
if (primaryGpu?.name) {
|
||||
chips.push({
|
||||
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
|
||||
if (primaryLinkedUser?.email) {
|
||||
const collaboratorValue = primaryLinkedUser.name ? `${primaryLinkedUser.name} · ${primaryLinkedUser.email}` : primaryLinkedUser.email
|
||||
chips.push({
|
||||
key: "collaborator",
|
||||
label: personaLabel,
|
||||
|
|
@ -1865,9 +1919,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
icon: <ShieldCheck className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
if (remoteAccess && (remoteAccess.identifier || remoteAccess.url)) {
|
||||
const value = remoteAccess.identifier ?? remoteAccess.url ?? "—"
|
||||
const label = remoteAccess.provider ? `Acesso (${remoteAccess.provider})` : "Acesso remoto"
|
||||
const primaryRemoteAccess = remoteAccessEntries[0]
|
||||
if (primaryRemoteAccess && (primaryRemoteAccess.identifier || primaryRemoteAccess.url)) {
|
||||
const value = primaryRemoteAccess.identifier ?? primaryRemoteAccess.url ?? "—"
|
||||
const label = primaryRemoteAccess.provider ? `Acesso (${primaryRemoteAccess.provider})` : "Acesso remoto"
|
||||
chips.push({
|
||||
key: "remote-access",
|
||||
label,
|
||||
|
|
@ -1876,7 +1931,19 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
})
|
||||
}
|
||||
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
|
||||
|
||||
|
|
@ -1888,13 +1955,12 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [accessDialog, setAccessDialog] = useState(false)
|
||||
const [accessEmail, setAccessEmail] = useState<string>(collaborator?.email ?? "")
|
||||
const [accessName, setAccessName] = useState<string>(collaborator?.name ?? "")
|
||||
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(
|
||||
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
|
||||
)
|
||||
const [accessEmail, setAccessEmail] = useState<string>(primaryLinkedUser?.email ?? "")
|
||||
const [accessName, setAccessName] = useState<string>(primaryLinkedUser?.name ?? "")
|
||||
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(personaRole === "manager" ? "manager" : "collaborator")
|
||||
const [savingAccess, setSavingAccess] = useState(false)
|
||||
const [remoteAccessDialog, setRemoteAccessDialog] = useState(false)
|
||||
const [editingRemoteAccessClientId, setEditingRemoteAccessClientId] = useState<string | null>(null)
|
||||
const [remoteAccessProviderOption, setRemoteAccessProviderOption] = useState<RemoteAccessProviderValue>(
|
||||
REMOTE_ACCESS_PROVIDERS[0].value,
|
||||
)
|
||||
|
|
@ -1903,6 +1969,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
const [remoteAccessUrlInput, setRemoteAccessUrlInput] = useState("")
|
||||
const [remoteAccessNotesInput, setRemoteAccessNotesInput] = useState("")
|
||||
const [remoteAccessSaving, setRemoteAccessSaving] = useState(false)
|
||||
const editingRemoteAccess = useMemo(
|
||||
() => remoteAccessEntries.find((entry) => entry.clientId === editingRemoteAccessClientId) ?? null,
|
||||
[editingRemoteAccessClientId, remoteAccessEntries]
|
||||
)
|
||||
const [togglingActive, setTogglingActive] = useState(false)
|
||||
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
|
||||
const jsonText = useMemo(() => {
|
||||
|
|
@ -1942,14 +2012,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
|
||||
// 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])
|
||||
setAccessEmail(primaryLinkedUser?.email ?? "")
|
||||
setAccessName(primaryLinkedUser?.name ?? "")
|
||||
setAccessRole(personaRole === "manager" ? "manager" : "collaborator")
|
||||
}, [machine?.id, primaryLinkedUser?.email, primaryLinkedUser?.name, personaRole])
|
||||
|
||||
useEffect(() => {
|
||||
setIsActiveLocal(machine?.isActive ?? true)
|
||||
}, [machine?.isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!remoteAccessDialog) return
|
||||
const providerName = remoteAccess?.provider ?? ""
|
||||
const providerName = editingRemoteAccess?.provider ?? ""
|
||||
const matched = REMOTE_ACCESS_PROVIDERS.find(
|
||||
(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)
|
||||
setRemoteAccessCustomProvider(providerName ?? "")
|
||||
}
|
||||
setRemoteAccessIdentifierInput(remoteAccess?.identifier ?? "")
|
||||
setRemoteAccessUrlInput(remoteAccess?.url ?? "")
|
||||
setRemoteAccessNotesInput(remoteAccess?.notes ?? "")
|
||||
}, [remoteAccessDialog, remoteAccess])
|
||||
setRemoteAccessIdentifierInput(editingRemoteAccess?.identifier ?? "")
|
||||
setRemoteAccessUrlInput(editingRemoteAccess?.url ?? "")
|
||||
setRemoteAccessNotesInput(editingRemoteAccess?.notes ?? "")
|
||||
}, [remoteAccessDialog, editingRemoteAccess])
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteAccessDialog) return
|
||||
if (!remoteAccess) {
|
||||
if (!editingRemoteAccessClientId) {
|
||||
setRemoteAccessProviderOption(REMOTE_ACCESS_PROVIDERS[0].value)
|
||||
setRemoteAccessCustomProvider("")
|
||||
setRemoteAccessIdentifierInput("")
|
||||
setRemoteAccessUrlInput("")
|
||||
setRemoteAccessNotesInput("")
|
||||
}
|
||||
}, [remoteAccess, remoteAccessDialog])
|
||||
}, [editingRemoteAccessClientId, remoteAccessDialog])
|
||||
|
||||
useEffect(() => {
|
||||
setShowAllWindowsSoftware(false)
|
||||
|
|
@ -2060,6 +2134,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
setRemoteAccessSaving(true)
|
||||
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -2069,18 +2158,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
identifier,
|
||||
url: normalizedUrl,
|
||||
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) {
|
||||
const message = typeof payload?.error === "string" ? payload.error : "Falha ao atualizar acesso remoto."
|
||||
throw new Error(message)
|
||||
const message = typeof responsePayload?.error === "string" ? responsePayload.error : "Falha ao atualizar acesso remoto."
|
||||
const detailMessage = typeof responsePayload?.detail === "string" ? responsePayload.detail : null
|
||||
throw new Error(detailMessage ? `${message}. ${detailMessage}` : message)
|
||||
}
|
||||
|
||||
toast.success("Acesso remoto atualizado.", { id: "remote-access" })
|
||||
setRemoteAccessDialog(false)
|
||||
setEditingRemoteAccessClientId(null)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Falha ao atualizar acesso remoto."
|
||||
toast.error(message, { id: "remote-access" })
|
||||
|
|
@ -2095,9 +2187,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
remoteAccessIdentifierInput,
|
||||
remoteAccessUrlInput,
|
||||
remoteAccessNotesInput,
|
||||
editingRemoteAccess,
|
||||
])
|
||||
|
||||
const handleRemoveRemoteAccess = useCallback(async () => {
|
||||
const handleRemoveRemoteAccess = useCallback(async (entry: MachineRemoteAccessEntry) => {
|
||||
if (!machine) return
|
||||
if (!canManageRemoteAccess) {
|
||||
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" })
|
||||
setRemoteAccessSaving(true)
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
machineId: machine.id,
|
||||
action: "clear",
|
||||
}),
|
||||
body: JSON.stringify(requestPayload),
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
const responsePayload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
const message = typeof payload?.error === "string" ? payload.error : "Falha ao remover acesso remoto."
|
||||
throw new Error(message)
|
||||
const message = typeof responsePayload?.error === "string" ? responsePayload.error : "Falha ao remover acesso remoto."
|
||||
const detailMessage = typeof responsePayload?.detail === "string" ? responsePayload.detail : null
|
||||
throw new Error(detailMessage ? `${message}. ${detailMessage}` : message)
|
||||
}
|
||||
toast.success("Acesso remoto removido.", { id: "remote-access" })
|
||||
setRemoteAccessDialog(false)
|
||||
setEditingRemoteAccessClientId(null)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Falha ao remover acesso remoto."
|
||||
toast.error(message, { id: "remote-access" })
|
||||
|
|
@ -2132,37 +2239,40 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
|
||||
const handleToggleActive = async () => {
|
||||
if (!machine) return
|
||||
const nextActive = !isActiveLocal
|
||||
setIsActiveLocal(nextActive)
|
||||
setTogglingActive(true)
|
||||
try {
|
||||
const response = await fetch("/api/admin/machines/toggle-active", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machineId: machine.id, active: !isActive }),
|
||||
body: JSON.stringify({ machineId: machine.id, active: nextActive }),
|
||||
credentials: "include",
|
||||
})
|
||||
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")
|
||||
}
|
||||
toast.success(!isActive ? "Máquina reativada" : "Máquina desativada")
|
||||
toast.success(nextActive ? "Máquina reativada" : "Máquina desativada")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setIsActiveLocal(!nextActive)
|
||||
toast.error("Não foi possível atualizar o status da máquina.")
|
||||
} finally {
|
||||
setTogglingActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyRemoteIdentifier = useCallback(async () => {
|
||||
if (!remoteAccess?.identifier) return
|
||||
const handleCopyRemoteIdentifier = useCallback(async (identifier: string | null | undefined) => {
|
||||
if (!identifier) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(remoteAccess.identifier)
|
||||
await navigator.clipboard.writeText(identifier)
|
||||
toast.success("Identificador de acesso remoto copiado.")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível copiar o identificador.")
|
||||
}
|
||||
}, [remoteAccess?.identifier])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
|
|
@ -2178,7 +2288,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</div>
|
||||
) : 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">
|
||||
Máquina desativada
|
||||
</Badge>
|
||||
|
|
@ -2213,21 +2323,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} />
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/40 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-semibold text-indigo-900">Tickets abertos por esta máquina</h4>
|
||||
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold uppercase tracking-wide text-indigo-700">
|
||||
{machineTickets.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
|
||||
{machineTickets.length === 0 ? (
|
||||
<p className="mt-3 text-xs text-indigo-700">Nenhum chamado em aberto registrado diretamente por esta máquina.</p>
|
||||
<p className="text-xs text-[color:var(--accent-foreground)]/80">Nenhum chamado em aberto registrado diretamente por esta máquina.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
<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-indigo-200 bg-white px-3 py-2 text-sm shadow-sm"
|
||||
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">
|
||||
|
|
@ -2250,6 +2357,15 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{machine.authEmail ? (
|
||||
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
|
||||
|
|
@ -2263,30 +2379,37 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isActive ? "outline" : "default"}
|
||||
variant={isActiveLocal ? "outline" : "default"}
|
||||
className={cn(
|
||||
"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}
|
||||
disabled={togglingActive}
|
||||
>
|
||||
{isActive ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
|
||||
{isActive ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
|
||||
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
|
||||
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
|
||||
</Button>
|
||||
{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}
|
||||
</Badge>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">Acesso remoto</h4>
|
||||
{remoteAccess?.provider ? (
|
||||
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold text-indigo-700">
|
||||
{remoteAccess.provider}
|
||||
{hasRemoteAccess ? (
|
||||
<Badge variant="outline" className="border-slate-200 bg-slate-100 text-[11px] font-semibold text-slate-700">
|
||||
{remoteAccessEntries.length === 1
|
||||
? remoteAccessEntries[0].provider ?? "Configuração única"
|
||||
: `${remoteAccessEntries.length} acessos`}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -2295,59 +2418,114 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2 border-dashed"
|
||||
onClick={() => setRemoteAccessDialog(true)}
|
||||
onClick={() => {
|
||||
setEditingRemoteAccessClientId(null)
|
||||
setRemoteAccessDialog(true)
|
||||
}}
|
||||
>
|
||||
<Key className="size-4" />
|
||||
{hasRemoteAccess ? "Editar acesso" : "Adicionar acesso"}
|
||||
{hasRemoteAccess ? "Adicionar acesso" : "Cadastrar acesso"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{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="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-3">
|
||||
{remoteAccessEntries.map((entry) => {
|
||||
const metadataEntries = extractRemoteAccessMetadataEntries(entry.metadata)
|
||||
const lastVerifiedDate =
|
||||
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
|
||||
? new Date(entry.lastVerifiedAt)
|
||||
: null
|
||||
return (
|
||||
<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">
|
||||
{remoteAccess?.identifier ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-slate-900">{remoteAccess.identifier}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-2 px-2" onClick={handleCopyRemoteIdentifier}>
|
||||
{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>
|
||||
</div>
|
||||
) : null}
|
||||
{remoteAccess?.url ? (
|
||||
</div>
|
||||
{entry.url ? (
|
||||
<a
|
||||
href={remoteAccess.url}
|
||||
href={entry.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 text-indigo-700 underline-offset-4 hover:underline"
|
||||
className="inline-flex items-center gap-2 text-slate-600 underline-offset-4 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Abrir console remoto
|
||||
</a>
|
||||
) : null}
|
||||
{remoteAccess?.notes ? (
|
||||
<p className="whitespace-pre-wrap text-[11px] text-slate-600">{remoteAccess.notes}</p>
|
||||
{entry.notes ? (
|
||||
<p className="whitespace-pre-wrap text-[11px] text-slate-600">{entry.notes}</p>
|
||||
) : null}
|
||||
{remoteAccessLastVerifiedDate ? (
|
||||
{lastVerifiedDate ? (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Atualizado {formatRelativeTime(remoteAccessLastVerifiedDate)}{" "}
|
||||
<span className="text-slate-400">({formatAbsoluteDateTime(remoteAccessLastVerifiedDate)})</span>
|
||||
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>
|
||||
{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">
|
||||
) : null}
|
||||
</div>
|
||||
{metadataEntries.length ? (
|
||||
<details className="mt-3 rounded-lg border border-slate-200 bg-white/70 px-3 py-2 text-[11px] text-slate-600">
|
||||
<summary className="cursor-pointer font-semibold text-slate-700 outline-none transition-colors hover:text-slate-900">
|
||||
Metadados adicionais
|
||||
</summary>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{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">
|
||||
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
|
||||
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : 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.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2357,11 +2535,11 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Usuários vinculados</h4>
|
||||
<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">
|
||||
<ShieldCheck className="size-4 text-slate-500" />
|
||||
<span className="font-medium text-neutral-800">{collaborator.name || collaborator.email}</span>
|
||||
<span className="text-neutral-500">{collaborator.name ? `· ${collaborator.email}` : ''}</span>
|
||||
<span className="font-medium text-neutral-800">{primaryLinkedUser.name || primaryLinkedUser.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>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -2394,7 +2572,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
))}
|
||||
</ul>
|
||||
) : 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>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
|
|
@ -2431,7 +2609,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
Adicionar vínculo
|
||||
</Button>
|
||||
<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>
|
||||
</section>
|
||||
|
|
@ -2518,14 +2696,15 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
setRemoteAccessDialog(open)
|
||||
if (!open) {
|
||||
setRemoteAccessSaving(false)
|
||||
setEditingRemoteAccessClientId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Detalhes de acesso remoto</DialogTitle>
|
||||
<DialogTitle>{editingRemoteAccess ? "Editar acesso remoto" : "Adicionar acesso remoto"}</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<form
|
||||
|
|
@ -2591,12 +2770,12 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
/>
|
||||
</div>
|
||||
<DialogFooter className="flex flex-wrap items-center justify-between gap-2">
|
||||
{hasRemoteAccess ? (
|
||||
{editingRemoteAccess ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
onClick={handleRemoveRemoteAccess}
|
||||
onClick={() => editingRemoteAccess && void handleRemoveRemoteAccess(editingRemoteAccess)}
|
||||
disabled={remoteAccessSaving}
|
||||
>
|
||||
Remover acesso
|
||||
|
|
@ -2614,7 +2793,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={remoteAccessSaving}>
|
||||
{remoteAccessSaving ? "Salvando..." : "Salvar"}
|
||||
{remoteAccessSaving ? "Salvando..." : editingRemoteAccess ? "Atualizar" : "Salvar"}
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
Usuário vinculado: {collaborator.name ? `${collaborator.name} · ` : ""}{collaborator.email}
|
||||
</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>
|
||||
) : null}
|
||||
{!isActive ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue