Add USB storage device control feature
- Add USB policy fields to machines schema (policy, status, error) - Create usbPolicyEvents table for audit logging - Implement Convex mutations/queries for USB policy management - Add REST API endpoints for desktop agent communication - Create Rust usb_control module for Windows registry manipulation - Integrate USB policy check in agent heartbeat loop - Add USB policy control component in admin device overview - Add localhost:3001 to auth trustedOrigins for dev 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0e9310d6e4
commit
49aa143a80
11 changed files with 1116 additions and 1 deletions
97
src/app/api/machines/usb-policy/route.ts
Normal file
97
src/app/api/machines/usb-policy/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
const getPolicySchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
})
|
||||
|
||||
const reportStatusSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
status: z.enum(["PENDING", "APPLIED", "FAILED"]),
|
||||
error: z.string().optional(),
|
||||
currentPolicy: z.string().optional(),
|
||||
})
|
||||
|
||||
const CORS_METHODS = "GET, POST, OPTIONS"
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const origin = request.headers.get("origin")
|
||||
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const machineToken = url.searchParams.get("machineToken")
|
||||
|
||||
if (!machineToken) {
|
||||
return jsonWithCors({ error: "machineToken e obrigatorio" }, 400, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingPolicy = await client.query(api.usbPolicy.getPendingUsbPolicy, { machineToken })
|
||||
|
||||
if (!pendingPolicy) {
|
||||
return jsonWithCors({ pending: false }, 200, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
return jsonWithCors({
|
||||
pending: true,
|
||||
policy: pendingPolicy.policy,
|
||||
appliedAt: pendingPolicy.appliedAt,
|
||||
}, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.usb-policy] Falha ao buscar politica USB", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao buscar politica USB", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const origin = request.headers.get("origin")
|
||||
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = reportStatusSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return jsonWithCors(
|
||||
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.mutation(api.usbPolicy.reportUsbPolicyStatus, payload)
|
||||
return jsonWithCors(response, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.usb-policy] Falha ao reportar status de politica USB", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao reportar status", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ import type { Id } from "@/convex/_generated/dataModel"
|
|||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager"
|
||||
import { UsbPolicyControl } from "@/components/admin/devices/usb-policy-control"
|
||||
import { DatePicker } from "@/components/ui/date-picker"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
|
|
@ -4961,6 +4962,16 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManageRemoteAccess && device?.osName?.toLowerCase().includes("windows") ? (
|
||||
<UsbPolicyControl
|
||||
machineId={device.id}
|
||||
machineName={device.displayName ?? device.hostname}
|
||||
actorEmail={primaryLinkedUser?.email}
|
||||
actorName={primaryLinkedUser?.name}
|
||||
disabled={isDeactivated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{windowsCpuDetails.length > 0 ? (
|
||||
|
|
|
|||
296
src/components/admin/devices/usb-policy-control.tsx
Normal file
296
src/components/admin/devices/usb-policy-control.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } 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 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
||||
type UsbPolicyValue = "ALLOW" | "BLOCK_ALL" | "READONLY"
|
||||
|
||||
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 "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
|
||||
}
|
||||
}
|
||||
|
||||
interface UsbPolicyControlProps {
|
||||
machineId: string
|
||||
machineName?: string
|
||||
actorEmail?: string
|
||||
actorName?: string
|
||||
actorId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function UsbPolicyControl({
|
||||
machineId,
|
||||
machineName,
|
||||
actorEmail,
|
||||
actorName,
|
||||
actorId,
|
||||
disabled = false,
|
||||
}: UsbPolicyControlProps) {
|
||||
const [selectedPolicy, setSelectedPolicy] = useState<UsbPolicyValue>("ALLOW")
|
||||
const [isApplying, setIsApplying] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const usbPolicy = useQuery(api.usbPolicy.getUsbPolicy, {
|
||||
machineId: machineId as Id<"machines">,
|
||||
})
|
||||
|
||||
const policyEvents = useQuery(
|
||||
api.usbPolicy.listUsbPolicyEvents,
|
||||
showHistory ? { machineId: machineId as Id<"machines">, limit: 10 } : "skip"
|
||||
)
|
||||
|
||||
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 politica selecionada ja esta 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("Politica USB enviada para aplicacao.")
|
||||
} catch (error) {
|
||||
console.error("[usb-policy] Falha ao aplicar politica", error)
|
||||
toast.error("Falha ao aplicar politica USB. Tente novamente.")
|
||||
} finally {
|
||||
setIsApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatEventDate = (timestamp: number) => {
|
||||
return formatDistanceToNow(new Date(timestamp), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
{usbPolicy?.status && getStatusBadge(usbPolicy.status)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Gerencie o acesso a dispositivos de armazenamento USB neste dispositivo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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>
|
||||
|
||||
{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 aplicacao</p>
|
||||
<p className="text-xs text-red-600">{usbPolicy.error}</p>
|
||||
</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 politica</label>
|
||||
<Select
|
||||
value={selectedPolicy}
|
||||
onValueChange={(value) => setSelectedPolicy(value as UsbPolicyValue)}
|
||||
disabled={disabled || isApplying}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma politica" />
|
||||
</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 politica ja esta aplicada"
|
||||
: `Aplicar politica "${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 historico" : "Ver historico de alteracoes"}
|
||||
</Button>
|
||||
|
||||
{showHistory && policyEvents && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{policyEvents.length === 0 ? (
|
||||
<p className="text-center text-xs text-muted-foreground py-2">
|
||||
Nenhuma alteracao registrada
|
||||
</p>
|
||||
) : (
|
||||
policyEvents.map((event) => (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{usbPolicy?.reportedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ultimo relato do agente: {formatEventDate(usbPolicy.reportedAt)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ export const auth = betterAuth({
|
|||
env.NEXT_PUBLIC_APP_URL,
|
||||
process.env.NODE_ENV !== "production" ? "http://localhost:3000" : undefined,
|
||||
process.env.NODE_ENV !== "production" ? "http://127.0.0.1:3000" : undefined,
|
||||
process.env.NODE_ENV !== "production" ? "http://localhost:3001" : undefined,
|
||||
process.env.NODE_ENV !== "production" ? "http://127.0.0.1:3001" : undefined,
|
||||
].filter(Boolean) as string[]
|
||||
)
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue