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" });
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({
}
},
});

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({
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 }
},
})

View file

@ -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">

View file

@ -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 }
)
}
}

View file

@ -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 ? (