- Shows affected devices: pen drives, external HDs, SD cards - Clarifies that keyboards, mice, printers are not affected - Uses subtle blue info card design with device chips 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useMemo } from "react"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { Usb, Shield, ShieldOff, ShieldAlert, Clock, CheckCircle2, XCircle, Loader2, History, Filter, ChevronDown, RotateCcw, Info, HardDrive } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
import { formatDistanceToNow, startOfDay, endOfDay, parseISO } from "date-fns"
|
|
import { ptBR } from "date-fns/locale"
|
|
import { DateRangeButton } from "@/components/date-range-button"
|
|
|
|
type UsbPolicyValue = "ALLOW" | "BLOCK_ALL" | "READONLY"
|
|
|
|
interface UsbPolicyEvent {
|
|
id: string
|
|
oldPolicy?: string
|
|
newPolicy: string
|
|
status: string
|
|
error?: string
|
|
actorEmail?: string
|
|
actorName?: string
|
|
createdAt: number
|
|
appliedAt?: number
|
|
}
|
|
|
|
const POLICY_OPTIONS: Array<{ value: UsbPolicyValue; label: string; description: string; icon: typeof Shield }> = [
|
|
{
|
|
value: "ALLOW",
|
|
label: "Permitido",
|
|
description: "Acesso total a dispositivos USB de armazenamento",
|
|
icon: Shield,
|
|
},
|
|
{
|
|
value: "BLOCK_ALL",
|
|
label: "Bloqueado",
|
|
description: "Nenhum acesso a dispositivos USB de armazenamento",
|
|
icon: ShieldOff,
|
|
},
|
|
{
|
|
value: "READONLY",
|
|
label: "Somente leitura",
|
|
description: "Permite leitura, bloqueia escrita em dispositivos USB",
|
|
icon: ShieldAlert,
|
|
},
|
|
]
|
|
|
|
function getPolicyConfig(policy: string | undefined | null) {
|
|
return POLICY_OPTIONS.find((opt) => opt.value === policy) ?? POLICY_OPTIONS[0]
|
|
}
|
|
|
|
function getStatusBadge(status: string | undefined | null) {
|
|
switch (status) {
|
|
case "PENDING":
|
|
return (
|
|
<Badge variant="outline" className="gap-1 border-amber-200 bg-amber-50 text-amber-700">
|
|
<Clock className="size-3" />
|
|
Pendente
|
|
</Badge>
|
|
)
|
|
case "APPLYING":
|
|
return (
|
|
<Badge variant="outline" className="gap-1 border-blue-200 bg-blue-50 text-blue-700">
|
|
<Loader2 className="size-3 animate-spin" />
|
|
Aplicando
|
|
</Badge>
|
|
)
|
|
case "APPLIED":
|
|
return (
|
|
<Badge variant="outline" className="gap-1 border-emerald-200 bg-emerald-50 text-emerald-700">
|
|
<CheckCircle2 className="size-3" />
|
|
Aplicado
|
|
</Badge>
|
|
)
|
|
case "FAILED":
|
|
return (
|
|
<Badge variant="outline" className="gap-1 border-red-200 bg-red-50 text-red-700">
|
|
<XCircle className="size-3" />
|
|
Falhou
|
|
</Badge>
|
|
)
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getProgressValue(status: string | undefined | null): number {
|
|
switch (status) {
|
|
case "PENDING":
|
|
return 33
|
|
case "APPLYING":
|
|
return 66
|
|
case "APPLIED":
|
|
return 100
|
|
case "FAILED":
|
|
return 100
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function getProgressColor(status: string | undefined | null): string {
|
|
switch (status) {
|
|
case "PENDING":
|
|
return "bg-amber-500"
|
|
case "APPLYING":
|
|
return "bg-blue-500"
|
|
case "APPLIED":
|
|
return "bg-emerald-500"
|
|
case "FAILED":
|
|
return "bg-red-500"
|
|
default:
|
|
return "bg-neutral-300"
|
|
}
|
|
}
|
|
|
|
interface UsbPolicyControlProps {
|
|
machineId: string
|
|
machineName?: string
|
|
actorEmail?: string
|
|
actorName?: string
|
|
actorId?: string
|
|
disabled?: boolean
|
|
variant?: "card" | "inline"
|
|
}
|
|
|
|
const STATUS_FILTER_OPTIONS = [
|
|
{ value: "all", label: "Todos os status" },
|
|
{ value: "PENDING", label: "Pendente" },
|
|
{ value: "APPLYING", label: "Aplicando" },
|
|
{ value: "APPLIED", label: "Aplicado" },
|
|
{ value: "FAILED", label: "Falhou" },
|
|
]
|
|
|
|
export function UsbPolicyControl({
|
|
machineId,
|
|
machineName,
|
|
actorEmail,
|
|
actorName,
|
|
actorId,
|
|
disabled = false,
|
|
variant = "card",
|
|
}: UsbPolicyControlProps) {
|
|
const [selectedPolicy, setSelectedPolicy] = useState<UsbPolicyValue>("ALLOW")
|
|
const [isApplying, setIsApplying] = useState(false)
|
|
const [showHistory, setShowHistory] = useState(false)
|
|
|
|
// Filtros do historico
|
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
|
const [dateFrom, setDateFrom] = useState<string | null>(null)
|
|
const [dateTo, setDateTo] = useState<string | null>(null)
|
|
const [allEvents, setAllEvents] = useState<UsbPolicyEvent[]>([])
|
|
const [cursor, setCursor] = useState<number | undefined>(undefined)
|
|
|
|
const usbPolicy = useQuery(api.usbPolicy.getUsbPolicy, {
|
|
machineId: machineId as Id<"machines">,
|
|
})
|
|
|
|
// Converte datas para timestamp
|
|
const dateFromTs = useMemo(() => {
|
|
if (!dateFrom) return undefined
|
|
return startOfDay(parseISO(dateFrom)).getTime()
|
|
}, [dateFrom])
|
|
|
|
const dateToTs = useMemo(() => {
|
|
if (!dateTo) return undefined
|
|
return endOfDay(parseISO(dateTo)).getTime()
|
|
}, [dateTo])
|
|
|
|
const policyEventsResult = useQuery(
|
|
api.usbPolicy.listUsbPolicyEvents,
|
|
showHistory
|
|
? {
|
|
machineId: machineId as Id<"machines">,
|
|
limit: 10,
|
|
cursor,
|
|
status: statusFilter !== "all" ? statusFilter : undefined,
|
|
dateFrom: dateFromTs,
|
|
dateTo: dateToTs,
|
|
}
|
|
: "skip"
|
|
)
|
|
|
|
// Acumula eventos quando carrega mais
|
|
useEffect(() => {
|
|
if (policyEventsResult?.events) {
|
|
if (cursor === undefined) {
|
|
// Reset quando filtros mudam
|
|
setAllEvents(policyEventsResult.events)
|
|
} else {
|
|
// Acumula quando carrega mais
|
|
setAllEvents((prev) => [...prev, ...policyEventsResult.events])
|
|
}
|
|
}
|
|
}, [policyEventsResult?.events, cursor])
|
|
|
|
// Reset cursor quando filtros mudam
|
|
useEffect(() => {
|
|
setCursor(undefined)
|
|
setAllEvents([])
|
|
}, [statusFilter, dateFrom, dateTo])
|
|
|
|
const handleLoadMore = () => {
|
|
if (policyEventsResult?.nextCursor) {
|
|
setCursor(policyEventsResult.nextCursor)
|
|
}
|
|
}
|
|
|
|
const handleResetFilters = () => {
|
|
setStatusFilter("all")
|
|
setDateFrom(null)
|
|
setDateTo(null)
|
|
setCursor(undefined)
|
|
setAllEvents([])
|
|
}
|
|
|
|
const hasActiveFilters = statusFilter !== "all" || dateFrom !== null || dateTo !== null
|
|
|
|
const setUsbPolicyMutation = useMutation(api.usbPolicy.setUsbPolicy)
|
|
|
|
useEffect(() => {
|
|
if (usbPolicy?.policy) {
|
|
setSelectedPolicy(usbPolicy.policy as UsbPolicyValue)
|
|
}
|
|
}, [usbPolicy?.policy])
|
|
|
|
const currentConfig = getPolicyConfig(usbPolicy?.policy)
|
|
const CurrentIcon = currentConfig.icon
|
|
|
|
const handleApplyPolicy = async () => {
|
|
if (selectedPolicy === usbPolicy?.policy) {
|
|
toast.info("A política selecionada já está aplicada.")
|
|
return
|
|
}
|
|
|
|
setIsApplying(true)
|
|
try {
|
|
await setUsbPolicyMutation({
|
|
machineId: machineId as Id<"machines">,
|
|
policy: selectedPolicy,
|
|
actorId: actorId ? (actorId as Id<"users">) : undefined,
|
|
actorEmail,
|
|
actorName,
|
|
})
|
|
toast.success("Política USB enviada para aplicação.")
|
|
} catch (error) {
|
|
console.error("[usb-policy] Falha ao aplicar política", error)
|
|
toast.error("Falha ao aplicar política USB. Tente novamente.")
|
|
} finally {
|
|
setIsApplying(false)
|
|
}
|
|
}
|
|
|
|
const formatEventDate = (timestamp: number) => {
|
|
return formatDistanceToNow(new Date(timestamp), {
|
|
addSuffix: true,
|
|
locale: ptBR,
|
|
})
|
|
}
|
|
|
|
const content = (
|
|
<div className="space-y-4">
|
|
{/* Status atual com progress bar real */}
|
|
{usbPolicy?.status && (
|
|
<div className="space-y-2 rounded-lg border bg-slate-50 p-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-slate-600">Status da política</span>
|
|
{getStatusBadge(usbPolicy.status)}
|
|
</div>
|
|
{/* Progress bar real baseada no estado */}
|
|
{(usbPolicy.status === "PENDING" || usbPolicy.status === "APPLYING") && (
|
|
<div className="space-y-1">
|
|
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200">
|
|
<div
|
|
className={`h-full transition-all duration-500 ease-out ${getProgressColor(usbPolicy.status)}`}
|
|
style={{ width: `${getProgressValue(usbPolicy.status)}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{usbPolicy.status === "PENDING" ? "Aguardando agente..." : "Agente aplicando política..."}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{usbPolicy?.error && (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
|
<p className="text-sm font-medium text-red-700">Erro na aplicação</p>
|
|
<p className="text-xs text-red-600">{usbPolicy.error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Política atual */}
|
|
<div className="flex items-center gap-3 rounded-lg border bg-neutral-50 p-3">
|
|
<div className="flex size-10 items-center justify-center rounded-full bg-white shadow-sm">
|
|
<CurrentIcon className="size-5 text-neutral-600" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium">{currentConfig.label}</p>
|
|
<p className="text-xs text-muted-foreground">{currentConfig.description}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info sobre dispositivos afetados */}
|
|
<div className="flex items-start gap-2.5 rounded-lg border border-blue-100 bg-blue-50/50 p-3">
|
|
<Info className="mt-0.5 size-4 shrink-0 text-blue-500" />
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs font-medium text-blue-700">Dispositivos afetados</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700">
|
|
<HardDrive className="size-2.5" />
|
|
Pen drives
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700">
|
|
<HardDrive className="size-2.5" />
|
|
HDs externos
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700">
|
|
<HardDrive className="size-2.5" />
|
|
Cartões SD
|
|
</span>
|
|
</div>
|
|
<p className="text-[10px] text-blue-600/80">
|
|
Não afeta teclados, mouses, impressoras ou outros periféricos USB.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1 space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Alterar política</label>
|
|
<Select
|
|
value={selectedPolicy}
|
|
onValueChange={(value) => setSelectedPolicy(value as UsbPolicyValue)}
|
|
disabled={disabled || isApplying}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Selecione uma política" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{POLICY_OPTIONS.map((option) => {
|
|
const Icon = option.icon
|
|
return (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="size-4" />
|
|
<span>{option.label}</span>
|
|
</div>
|
|
</SelectItem>
|
|
)
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
onClick={handleApplyPolicy}
|
|
disabled={disabled || isApplying || selectedPolicy === usbPolicy?.policy}
|
|
size="default"
|
|
>
|
|
{isApplying ? (
|
|
<>
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
Aplicando...
|
|
</>
|
|
) : (
|
|
"Aplicar"
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{selectedPolicy === usbPolicy?.policy
|
|
? "A política já está aplicada"
|
|
: `Aplicar política "${getPolicyConfig(selectedPolicy).label}"`}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
|
|
<div className="border-t pt-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full justify-start gap-2 text-muted-foreground"
|
|
onClick={() => setShowHistory(!showHistory)}
|
|
>
|
|
<History className="size-4" />
|
|
{showHistory ? "Ocultar histórico" : "Ver histórico de alterações"}
|
|
</Button>
|
|
|
|
{showHistory && (
|
|
<div className="mt-3 space-y-3">
|
|
{/* Filtros */}
|
|
<div className="space-y-2 rounded-lg border bg-slate-50/80 p-3">
|
|
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
<Filter className="size-3.5" />
|
|
<span>Filtros</span>
|
|
{hasActiveFilters && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="ml-auto h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={handleResetFilters}
|
|
>
|
|
<RotateCcw className="size-3" />
|
|
Limpar
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue placeholder="Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STATUS_FILTER_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<DateRangeButton
|
|
from={dateFrom}
|
|
to={dateTo}
|
|
onChange={({ from, to }) => {
|
|
setDateFrom(from)
|
|
setDateTo(to)
|
|
}}
|
|
className="h-9"
|
|
clearLabel="Limpar"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lista de eventos */}
|
|
{allEvents.length === 0 ? (
|
|
<p className="text-center text-xs text-muted-foreground py-4">
|
|
{hasActiveFilters
|
|
? "Nenhuma alteração encontrada com os filtros selecionados"
|
|
: "Nenhuma alteração registrada"}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div className="space-y-2">
|
|
{allEvents.map((event: UsbPolicyEvent) => (
|
|
<div
|
|
key={event.id}
|
|
className="flex items-start justify-between rounded-md border bg-white p-2 text-xs"
|
|
>
|
|
<div className="space-y-0.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="font-medium">
|
|
{getPolicyConfig(event.oldPolicy).label}
|
|
</span>
|
|
<span className="text-muted-foreground">→</span>
|
|
<span className="font-medium">
|
|
{getPolicyConfig(event.newPolicy).label}
|
|
</span>
|
|
{getStatusBadge(event.status)}
|
|
</div>
|
|
<p className="text-muted-foreground">
|
|
{event.actorName ?? event.actorEmail ?? "Sistema"} · {formatEventDate(event.createdAt)}
|
|
</p>
|
|
{event.error && (
|
|
<p className="text-red-600">{event.error}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Paginacao */}
|
|
{policyEventsResult?.hasMore && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full gap-1 text-xs text-muted-foreground"
|
|
onClick={handleLoadMore}
|
|
>
|
|
<ChevronDown className="size-3.5" />
|
|
Carregar mais
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{usbPolicy?.reportedAt && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Último relato do agente: {formatEventDate(usbPolicy.reportedAt)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
if (variant === "inline") {
|
|
return content
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Usb className="size-5 text-neutral-500" />
|
|
<CardTitle className="text-base">Controle USB</CardTitle>
|
|
</div>
|
|
</div>
|
|
<CardDescription>
|
|
Gerencie o acesso a dispositivos de armazenamento USB neste dispositivo.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{content}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|