sistema-de-chamados/src/components/admin/devices/usb-policy-control.tsx
esdrasrenan a1292df245 Improve info card styling with system colors
- Use primary/accent colors instead of hardcoded blue
- Increase text sizes (text-sm for title, text-xs for chips and note)
- Increase icon and chip sizes for better readability
- Use text-secondary for chip text, text-muted-foreground for note

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:43:46 -03:00

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-3 rounded-lg border border-primary/20 bg-accent p-3">
<Info className="mt-0.5 size-4 shrink-0 text-primary" />
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Dispositivos afetados</p>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 px-2.5 py-1 text-xs font-medium text-secondary">
<HardDrive className="size-3" />
Pen drives
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 px-2.5 py-1 text-xs font-medium text-secondary">
<HardDrive className="size-3" />
HDs externos
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 px-2.5 py-1 text-xs font-medium text-secondary">
<HardDrive className="size-3" />
Cartões SD
</span>
</div>
<p className="text-xs text-muted-foreground">
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">&rarr;</span>
<span className="font-medium">
{getPolicyConfig(event.newPolicy).label}
</span>
{getStatusBadge(event.status)}
</div>
<p className="text-muted-foreground">
{event.actorName ?? event.actorEmail ?? "Sistema"} &middot; {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>
)
}