feat: cadastro manual de acesso remoto e ajustes de horas

This commit is contained in:
Esdras Renan 2025-10-24 23:52:58 -03:00
parent 8e3cbc7a9a
commit f3a7045691
16 changed files with 1549 additions and 207 deletions

View file

@ -36,12 +36,13 @@ import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Checkbox } from "@/components/ui/checkbox"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import {
Table,
TableBody,
@ -783,6 +784,19 @@ const statusClasses: Record<string, string> = {
unknown: "border-slate-300 bg-slate-200 text-slate-700",
}
const REMOTE_ACCESS_PROVIDERS = [
{ value: "TEAMVIEWER", label: "TeamViewer" },
{ value: "ANYDESK", label: "AnyDesk" },
{ value: "SUPREMO", label: "Supremo" },
{ value: "RUSTDESK", label: "RustDesk" },
{ value: "QUICKSUPPORT", label: "TeamViewer QS" },
{ value: "CHROME_REMOTE_DESKTOP", label: "Chrome Remote Desktop" },
{ value: "DW_SERVICE", label: "DWService" },
{ value: "OTHER", label: "Outro" },
] as const
type RemoteAccessProviderValue = (typeof REMOTE_ACCESS_PROVIDERS)[number]["value"]
const POSTURE_ALERT_LABELS: Record<string, string> = {
CPU_HIGH: "CPU alta",
SERVICE_DOWN: "Serviço indisponível",
@ -1280,6 +1294,9 @@ type MachineDetailsProps = {
export function MachineDetails({ machine }: MachineDetailsProps) {
const router = useRouter()
const { role: viewerRole } = useAuth()
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"
@ -1839,6 +1856,15 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
)
const [savingAccess, setSavingAccess] = useState(false)
const [remoteAccessDialog, setRemoteAccessDialog] = useState(false)
const [remoteAccessProviderOption, setRemoteAccessProviderOption] = useState<RemoteAccessProviderValue>(
REMOTE_ACCESS_PROVIDERS[0].value,
)
const [remoteAccessCustomProvider, setRemoteAccessCustomProvider] = useState("")
const [remoteAccessIdentifierInput, setRemoteAccessIdentifierInput] = useState("")
const [remoteAccessUrlInput, setRemoteAccessUrlInput] = useState("")
const [remoteAccessNotesInput, setRemoteAccessNotesInput] = useState("")
const [remoteAccessSaving, setRemoteAccessSaving] = useState(false)
const [togglingActive, setTogglingActive] = useState(false)
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
const jsonText = useMemo(() => {
@ -1883,6 +1909,35 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
setAccessRole((machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator")
}, [machine?.id, machine?.persona, collaborator?.email, collaborator?.name, collaborator?.role])
useEffect(() => {
if (!remoteAccessDialog) return
const providerName = remoteAccess?.provider ?? ""
const matched = REMOTE_ACCESS_PROVIDERS.find(
(option) => option.value !== "OTHER" && option.label.toLowerCase() === providerName.toLowerCase(),
)
if (matched) {
setRemoteAccessProviderOption(matched.value)
setRemoteAccessCustomProvider("")
} else {
setRemoteAccessProviderOption(providerName ? "OTHER" : REMOTE_ACCESS_PROVIDERS[0].value)
setRemoteAccessCustomProvider(providerName ?? "")
}
setRemoteAccessIdentifierInput(remoteAccess?.identifier ?? "")
setRemoteAccessUrlInput(remoteAccess?.url ?? "")
setRemoteAccessNotesInput(remoteAccess?.notes ?? "")
}, [remoteAccessDialog, remoteAccess])
useEffect(() => {
if (remoteAccessDialog) return
if (!remoteAccess) {
setRemoteAccessProviderOption(REMOTE_ACCESS_PROVIDERS[0].value)
setRemoteAccessCustomProvider("")
setRemoteAccessIdentifierInput("")
setRemoteAccessUrlInput("")
setRemoteAccessNotesInput("")
}
}, [remoteAccess, remoteAccessDialog])
useEffect(() => {
setShowAllWindowsSoftware(false)
}, [machine?.id])
@ -1923,6 +1978,120 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}
}
const handleSaveRemoteAccess = useCallback(async () => {
if (!machine) return
if (!canManageRemoteAccess) {
toast.error("Você não tem permissão para ajustar o acesso remoto desta máquina.")
return
}
const providerOption = REMOTE_ACCESS_PROVIDERS.find((option) => option.value === remoteAccessProviderOption)
const providerName =
remoteAccessProviderOption === "OTHER"
? remoteAccessCustomProvider.trim()
: providerOption?.label ?? ""
if (!providerName) {
toast.error("Informe a ferramenta de acesso remoto.")
return
}
const identifier = remoteAccessIdentifierInput.trim()
if (!identifier) {
toast.error("Informe o ID ou código do acesso remoto.")
return
}
let normalizedUrl: string | undefined
const rawUrl = remoteAccessUrlInput.trim()
if (rawUrl.length > 0) {
const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`
try {
new URL(candidate)
normalizedUrl = candidate
} catch {
toast.error("Informe uma URL válida (ex: https://example.com).")
return
}
}
const notes = remoteAccessNotesInput.trim()
toast.dismiss("remote-access")
toast.loading("Salvando acesso remoto...", { id: "remote-access" })
setRemoteAccessSaving(true)
try {
const response = await fetch("/api/admin/machines/remote-access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineId: machine.id,
provider: providerName,
identifier,
url: normalizedUrl,
notes: notes.length ? notes : undefined,
action: "save",
}),
})
const payload = 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)
}
toast.success("Acesso remoto atualizado.", { id: "remote-access" })
setRemoteAccessDialog(false)
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao atualizar acesso remoto."
toast.error(message, { id: "remote-access" })
} finally {
setRemoteAccessSaving(false)
}
}, [
machine,
canManageRemoteAccess,
remoteAccessProviderOption,
remoteAccessCustomProvider,
remoteAccessIdentifierInput,
remoteAccessUrlInput,
remoteAccessNotesInput,
])
const handleRemoveRemoteAccess = useCallback(async () => {
if (!machine) return
if (!canManageRemoteAccess) {
toast.error("Você não tem permissão para ajustar o acesso remoto desta máquina.")
return
}
toast.dismiss("remote-access")
toast.loading("Removendo acesso remoto...", { id: "remote-access" })
setRemoteAccessSaving(true)
try {
const response = await fetch("/api/admin/machines/remote-access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineId: machine.id,
action: "clear",
}),
})
const payload = 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)
}
toast.success("Acesso remoto removido.", { id: "remote-access" })
setRemoteAccessDialog(false)
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao remover acesso remoto."
toast.error(message, { id: "remote-access" })
} finally {
setRemoteAccessSaving(false)
}
}, [machine, canManageRemoteAccess])
const handleToggleActive = async () => {
if (!machine) return
setTogglingActive(true)
@ -2036,61 +2205,78 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</Badge>
) : 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-1">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-indigo-600">
<Key className="size-4" />
Acesso remoto
{remoteAccess?.provider ? (
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold text-indigo-700">
{remoteAccess.provider}
</Badge>
) : null}
</div>
{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}>
<ClipboardCopy className="size-3.5" /> Copiar ID
</Button>
</div>
) : null}
{remoteAccess?.url ? (
<a
href={remoteAccess.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-indigo-700 underline-offset-4 hover:underline"
>
Abrir console remoto
</a>
) : null}
{remoteAccess?.notes ? (
<p className="text-[11px] text-slate-600">{remoteAccess.notes}</p>
) : null}
{remoteAccessLastVerifiedDate ? (
<p className="text-[11px] text-slate-500">
Atualizado {formatRelativeTime(remoteAccessLastVerifiedDate)}
{" "}
<span className="text-slate-400">({formatAbsoluteDateTime(remoteAccessLastVerifiedDate)})</span>
</p>
) : 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}
</Badge>
) : null}
</div>
{remoteAccessMetadataEntries.length ? (
<div className="mt-3 grid gap-2 text-[11px] text-slate-600 sm:grid-cols-2">
{remoteAccessMetadataEntries.map(([key, value]) => (
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-indigo-100 bg-white px-2 py-1">
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
</div>
))}
</div>
{canManageRemoteAccess ? (
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed"
onClick={() => setRemoteAccessDialog(true)}
>
<Key className="size-4" />
{hasRemoteAccess ? "Editar acesso" : "Adicionar acesso"}
</Button>
) : null}
</div>
) : null}
{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-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}>
<ClipboardCopy className="size-3.5" /> Copiar ID
</Button>
</div>
) : null}
{remoteAccess?.url ? (
<a
href={remoteAccess.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-indigo-700 underline-offset-4 hover:underline"
>
Abrir console remoto
</a>
) : null}
{remoteAccess?.notes ? (
<p className="whitespace-pre-wrap text-[11px] text-slate-600">{remoteAccess.notes}</p>
) : null}
{remoteAccessLastVerifiedDate ? (
<p className="text-[11px] text-slate-500">
Atualizado {formatRelativeTime(remoteAccessLastVerifiedDate)}{" "}
<span className="text-slate-400">({formatAbsoluteDateTime(remoteAccessLastVerifiedDate)})</span>
</p>
) : null}
</div>
</div>
{remoteAccessMetadataEntries.length ? (
<div className="mt-3 grid gap-2 text-[11px] text-slate-600 sm:grid-cols-2">
{remoteAccessMetadataEntries.map(([key, value]) => (
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-indigo-100 bg-white px-2 py-1">
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
</div>
))}
</div>
) : null}
</div>
) : (
<div 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">
Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte.
</div>
)}
</div>
</section>
<section className="space-y-2">
@ -2251,6 +2437,116 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</DialogContent>
</Dialog>
<Dialog
open={remoteAccessDialog}
onOpenChange={(open) => {
setRemoteAccessDialog(open)
if (!open) {
setRemoteAccessSaving(false)
}
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Detalhes de acesso remoto</DialogTitle>
<DialogDescription>
Registre o provedor e o identificador utilizado para acesso remoto à máquina.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault()
void handleSaveRemoteAccess()
}}
className="space-y-4"
>
<div className="grid gap-2">
<label className="text-sm font-medium">Ferramenta</label>
<Select
value={remoteAccessProviderOption}
onValueChange={(value) => setRemoteAccessProviderOption(value as RemoteAccessProviderValue)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione o provedor" />
</SelectTrigger>
<SelectContent>
{REMOTE_ACCESS_PROVIDERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{remoteAccessProviderOption === "OTHER" ? (
<div className="grid gap-2">
<label className="text-sm font-medium">Nome da ferramenta</label>
<Input
value={remoteAccessCustomProvider}
onChange={(event) => setRemoteAccessCustomProvider(event.target.value)}
placeholder="Ex: Supremo, Zoho Assist..."
autoFocus
/>
</div>
) : null}
<div className="grid gap-2">
<label className="text-sm font-medium">ID / código</label>
<Input
value={remoteAccessIdentifierInput}
onChange={(event) => setRemoteAccessIdentifierInput(event.target.value)}
placeholder="Ex: 123 456 789"
required
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Link (opcional)</label>
<Input
value={remoteAccessUrlInput}
onChange={(event) => setRemoteAccessUrlInput(event.target.value)}
placeholder="https://"
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Observações</label>
<Textarea
value={remoteAccessNotesInput}
onChange={(event) => setRemoteAccessNotesInput(event.target.value)}
rows={3}
placeholder="Credencial compartilhada, PIN adicional, instruções..."
/>
</div>
<DialogFooter className="flex flex-wrap items-center justify-between gap-2">
{hasRemoteAccess ? (
<Button
type="button"
variant="outline"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
onClick={handleRemoveRemoteAccess}
disabled={remoteAccessSaving}
>
Remover acesso
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setRemoteAccessDialog(false)}
disabled={remoteAccessSaving}
>
Cancelar
</Button>
<Button type="submit" disabled={remoteAccessSaving}>
{remoteAccessSaving ? "Salvando..." : "Salvar"}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<section className="space-y-2">
<h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground">