feat: cadastro manual de acesso remoto e ajustes de horas
This commit is contained in:
parent
8e3cbc7a9a
commit
f3a7045691
16 changed files with 1549 additions and 207 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue