build 4.6 Demo
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
import { getApiUrl } from "@/config/api"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import { ArrowLeft, FileIcon, Upload, LogOut } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { FileUpload } from "@/components/file-upload"
|
||||
import type { Device, DeviceFile } from "@/types/device"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { getDeviceImage } from "@/components/device-images"
|
||||
import axios from "axios"
|
||||
import Image from "next/image"
|
||||
|
||||
interface ApiCapture {
|
||||
id: number
|
||||
filename: string
|
||||
upload: string
|
||||
firmware_version: string
|
||||
ip: string
|
||||
state: string
|
||||
}
|
||||
|
||||
const getDeviceById = async (deviceId: string) => {
|
||||
try {
|
||||
const deviceResponse = await axios.get(getApiUrl(`get_device_by_id/${deviceId}`))
|
||||
const deviceData = deviceResponse.data
|
||||
|
||||
const filesResponse = await axios.get(getApiUrl(`get_file_by_device/${deviceId}`))
|
||||
const filesData = filesResponse.data
|
||||
|
||||
const files: DeviceFile[] = filesData.map((file: ApiCapture) => ({
|
||||
id: String(file.id),
|
||||
name: file.filename,
|
||||
uploadedAt: file.upload,
|
||||
firmwareVersion: file.firmware_version,
|
||||
ipAddress: file.ip,
|
||||
status: file.state === "pending" ? "pending" : "scanned",
|
||||
}))
|
||||
|
||||
return {
|
||||
id: String(deviceData.id),
|
||||
name: deviceData.product.product_name,
|
||||
type: deviceData.product.device_type.device_type,
|
||||
manufacturer: deviceData.product.manufacturer.name,
|
||||
serialNumber: deviceData.serial,
|
||||
comment: deviceData.comment,
|
||||
lastScanned: new Date(deviceData.created).toLocaleDateString("en-GB"),
|
||||
files,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch device details:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function DeviceDetailPage() {
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
const router = useRouter()
|
||||
const [device, setDevice] = useState<Device | null>(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDeviceDetails = async () => {
|
||||
const deviceDetails = await getDeviceById(id)
|
||||
if (!deviceDetails) {
|
||||
router.push("/devices")
|
||||
} else {
|
||||
setDevice(deviceDetails)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDeviceDetails()
|
||||
}, [id, router])
|
||||
|
||||
const handleViewResults = (fileId: string) => {
|
||||
router.push(`/scan-results?fileId=${fileId}`)
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return <div className="min-h-screen bg-background" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
{({ logout }) => (
|
||||
<div className="min-h-screen bg-background p-4 flex items-start">
|
||||
<div className="flex gap-4 max-w-[1400px] mx-auto justify-center w-full h-[calc(100vh-32px)]">
|
||||
<div className="flex-1 space-y-8">
|
||||
<Card className="w-full h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex justify-end items-center mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
logout()
|
||||
router.push("/login")
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="sr-only">Logout</span>
|
||||
</Button>
|
||||
</div>
|
||||
{!showUpload && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<CardTitle className="text-2xl">{device.name}</CardTitle>
|
||||
<CardDescription className="space-y-1">
|
||||
<span className="block">Type: {device.type}</span>
|
||||
<span className="block">Manufacturer: {device.manufacturer}</span>
|
||||
<span className="block">Serial Number: {device.serialNumber}</span>
|
||||
{device.comment && <span className="block">Comment: {device.comment}</span>}
|
||||
{device.lastScanned && (
|
||||
<span className="block">Last Scanned: {device.lastScanned}</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="w-40 h-40 rounded-full overflow-hidden flex-shrink-0 border">
|
||||
<Image
|
||||
src={getDeviceImage(device.type) || "/images/logos/Placeholder.png"}
|
||||
alt={device.name}
|
||||
width={160}
|
||||
height={160}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow overflow-hidden relative">
|
||||
{showUpload ? (
|
||||
<FileUpload onBack={() => setShowUpload(false)} deviceId={id} />
|
||||
) : (
|
||||
<div className="space-y-6 h-full flex flex-col">
|
||||
<Button onClick={() => setShowUpload(true)} className="w-auto">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload New File
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4 flex-grow overflow-hidden">
|
||||
<h3 className="text-lg font-semibold">Device Files</h3>
|
||||
<ScrollArea className="h-[calc(100%-60px)] pr-4">
|
||||
{device.files && device.files.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{device.files.map((file) => (
|
||||
<div key={file.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center space-x-4">
|
||||
<FileIcon className="h-6 w-6 text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{file.name}</p>
|
||||
<div className="text-sm text-muted-foreground space-y-0.5">
|
||||
<p>Uploaded: {new Date(file.uploadedAt).toLocaleDateString("de-DE")}</p>
|
||||
{file.firmwareVersion && <p>Firmware: {file.firmwareVersion}</p>}
|
||||
{file.ipAddress && <p>IP Address: {file.ipAddress}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={() => handleViewResults(file.id)}
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No files uploaded yet. Upload a file to start scanning.
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-auto pt-4">
|
||||
<Button onClick={() => router.push("/devices")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Devices
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client"
|
||||
import { getApiUrl } from "@/config/api"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus, Trash2, LogOut, ChevronRight, Users, Settings } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import type { Device } from "@/types/device"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import Image from "next/image"
|
||||
import { getAllDevices, getManufacturers, getDeviceTypes, addDevice, deleteDevice } from "@/services/apiIOT"
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const router = useRouter()
|
||||
const { user, logout } = useAuth()
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [newDeviceName, setNewDeviceName] = useState("")
|
||||
const [newDeviceType, setNewDeviceType] = useState("")
|
||||
const [newManufacturer, setNewManufacturer] = useState("")
|
||||
const [newSerialNumber, setNewSerialNumber] = useState("")
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deviceToDelete, setDeviceToDelete] = useState<string | null>(null)
|
||||
const [deviceTypes, setDeviceTypes] = useState<SelectOption[]>([])
|
||||
const [manufacturers, setManufacturers] = useState<SelectOption[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDeviceTypes = async () => {
|
||||
const types = await getDeviceTypes()
|
||||
setDeviceTypes(types)
|
||||
}
|
||||
fetchDeviceTypes()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchManufacturers = async () => {
|
||||
const types = await getManufacturers()
|
||||
setManufacturers(types)
|
||||
}
|
||||
fetchManufacturers()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDevices = async () => {
|
||||
const fetchedDevices = await getAllDevices()
|
||||
setDevices(fetchedDevices)
|
||||
}
|
||||
fetchDevices()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push("/login")
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch(getApiUrl("logout"), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error)
|
||||
} finally {
|
||||
logout()
|
||||
router.push("/login")
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDevice = async () => {
|
||||
if (newDeviceName && newDeviceType && newManufacturer && newSerialNumber) {
|
||||
const newDevice = {
|
||||
device_name: newDeviceName,
|
||||
device_type_id: newDeviceType,
|
||||
manufacturer_id: newManufacturer,
|
||||
serial: newSerialNumber,
|
||||
comment: newComment,
|
||||
}
|
||||
try {
|
||||
await addDevice(newDevice)
|
||||
setDialogOpen(false)
|
||||
const updatedDevices = await getAllDevices()
|
||||
setDevices(updatedDevices)
|
||||
} catch (error) {
|
||||
console.error("Failed to add device:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setDeviceToDelete(id)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deviceToDelete) {
|
||||
try {
|
||||
await deleteDevice(deviceToDelete)
|
||||
const updatedDevices = await getAllDevices()
|
||||
setDevices(updatedDevices)
|
||||
setDeleteDialogOpen(false)
|
||||
setDeviceToDelete(null)
|
||||
} catch (error) {
|
||||
console.error("Failed to delete device:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToDevice = (deviceId: string) => {
|
||||
router.push(`/devices/${deviceId}`)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-background p-4">
|
||||
<div className="flex items-start max-w-[1400px] mx-auto">
|
||||
<div className="flex-1 flex justify-center">
|
||||
<div className="w-full max-w-full space-y-8">
|
||||
<Card>
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="w-64">
|
||||
<Image
|
||||
src="/images/logos/Logo.png"
|
||||
alt="IoT PreTester 4.6 Demo logo"
|
||||
width={128}
|
||||
height={128}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<Button variant="ghost" size="icon" onClick={handleLogout} className="ml-auto">
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="sr-only">Logout</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-2xl">My Devices</CardTitle>
|
||||
<CardDescription>Manage and monitor your IoT devices</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full sm:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add New Device
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-foreground">Add New Device</DialogTitle>
|
||||
<DialogDescription>Enter the details of your new IoT device.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-foreground">
|
||||
Device Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newDeviceName}
|
||||
onChange={(e) => setNewDeviceName(e.target.value)}
|
||||
placeholder="Enter device name"
|
||||
className="text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manufacturer" className="text-foreground">
|
||||
Manufacturer
|
||||
</Label>
|
||||
<Select value={newManufacturer} onValueChange={setNewManufacturer}>
|
||||
<SelectTrigger className="text-foreground">
|
||||
<SelectValue placeholder="Select manufacturer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{manufacturers.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-foreground">
|
||||
Device Type
|
||||
</Label>
|
||||
<Select value={newDeviceType} onValueChange={setNewDeviceType}>
|
||||
<SelectTrigger className="text-foreground">
|
||||
<SelectValue placeholder="Select device type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{deviceTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serialNumber" className="text-foreground">
|
||||
Serial Number
|
||||
</Label>
|
||||
<Input
|
||||
id="serialNumber"
|
||||
value={newSerialNumber}
|
||||
onChange={(e) => setNewSerialNumber(e.target.value)}
|
||||
placeholder="Enter serial number"
|
||||
className="text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment" className="text-foreground">
|
||||
Comment
|
||||
</Label>
|
||||
<Input
|
||||
id="comment"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Enter optional comment"
|
||||
className="text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddDevice}
|
||||
disabled={!newDeviceName || !newDeviceType || !newManufacturer || !newSerialNumber}
|
||||
>
|
||||
Add Device
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{(user.role === "admin" || user.role === "superuser") && (
|
||||
<>
|
||||
<Button className="w-full sm:w-auto" onClick={() => router.push("/manage-users")}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Users
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto" onClick={() => router.push("/manage-device-types")}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Manage Device Types
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto" onClick={() => router.push("/manage-manufacturers")}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Manage Manufacturers
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto"
|
||||
onClick={() => window.open("http://127.0.0.1:8000", "_blank")}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Manage Database
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[calc(100vh-19rem)]">
|
||||
<div className="space-y-4">
|
||||
{devices.map((device) => (
|
||||
<Card key={device.id} className="relative overflow-hidden bg-card">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="relative w-3/5 h-full ml-auto">
|
||||
{/*<Image*/}
|
||||
{/* src={getDeviceImage(device.type) || "/placeholder.svg"}*/}
|
||||
{/* alt={`${device.name} image`}*/}
|
||||
{/* layout="fill"*/}
|
||||
{/* className="object-cover opacity-75 mix-blend-multiply"*/}
|
||||
{/* priority={false}*/}
|
||||
{/*/>*/}
|
||||
</div>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{device.name}</CardTitle>
|
||||
<CardDescription className="space-y-1">
|
||||
<span className="block">
|
||||
{deviceTypes.find((t) => t.value === device.type)?.label || device.type}
|
||||
</span>
|
||||
<span className="block">
|
||||
Manufacturer:{" "}
|
||||
{manufacturers.find((m) => m.value === device.manufacturer)?.label ||
|
||||
device.manufacturer}
|
||||
</span>
|
||||
<span className="block">Serial Number: {device.serialNumber}</span>
|
||||
{device.comment && <span className="block text-sm">Comment: {device.comment}</span>}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(device.id)}
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete device</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4 relative z-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{device.files?.length || 0} files
|
||||
{device.lastScanned && ` • Last scan: ${device.lastScanned}`}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => navigateToDevice(device.id)} className="ml-2">
|
||||
View Device
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{devices.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No devices added yet. Add your first device to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">Delete Device</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this device? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,614 @@
|
||||
"use client"
|
||||
|
||||
import { getApiUrl } from "@/config/api"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { ArrowLeft, FileJson, FileText, FileCode, ShieldAlert, Globe, ChevronRight } from "lucide-react"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
|
||||
// API types mirror the backend finding schema.
|
||||
interface Finding {
|
||||
finding_id: string
|
||||
title: string
|
||||
category: string
|
||||
severity: "critical" | "high" | "medium" | "low" | "info"
|
||||
confidence: "high" | "medium" | "low"
|
||||
detector?: string
|
||||
affected_device?: string | null
|
||||
source_ip?: string | null
|
||||
destination_ip?: string | null
|
||||
protocol?: string | null
|
||||
port?: number | null
|
||||
risk_description: string
|
||||
recommendation: string
|
||||
evidence: Record<string, unknown>
|
||||
related_packets: number[]
|
||||
related_streams: number[]
|
||||
timestamp_first_seen?: number | null
|
||||
timestamp_last_seen?: number | null
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
total: number
|
||||
by_severity: Record<string, number>
|
||||
by_category: Record<string, number>
|
||||
highlights: Finding[]
|
||||
top: Finding[]
|
||||
}
|
||||
|
||||
interface AnalysisResult {
|
||||
meta?: Record<string, unknown>
|
||||
summary: Summary
|
||||
findings: Finding[]
|
||||
}
|
||||
|
||||
interface DeepStatus {
|
||||
state: "pending" | "running" | "done" | "error"
|
||||
url_total: number
|
||||
url_done: number
|
||||
hash_total: number
|
||||
hash_done: number
|
||||
cracked: number
|
||||
progress_done: number
|
||||
progress_total: number
|
||||
percent: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface CloudUrl {
|
||||
id: number
|
||||
url: string
|
||||
analyzed: boolean
|
||||
ports?: number[]
|
||||
tls_versions?: string[]
|
||||
cipher_suites?: string[]
|
||||
}
|
||||
|
||||
interface CloudHash {
|
||||
id: number
|
||||
hash_value: string
|
||||
analyzed: boolean
|
||||
cracked: boolean
|
||||
cracked_value?: string | null
|
||||
crack_time?: string | null
|
||||
hash_type?: string[]
|
||||
}
|
||||
|
||||
interface CloudResults {
|
||||
urls: CloudUrl[]
|
||||
hashes: CloudHash[]
|
||||
}
|
||||
|
||||
const SEV_ORDER = ["critical", "high", "medium", "low", "info"] as const
|
||||
|
||||
const SEV_STYLE: Record<string, string> = {
|
||||
critical: "bg-red-600 text-white",
|
||||
high: "bg-orange-500 text-white",
|
||||
medium: "bg-yellow-400 text-black",
|
||||
low: "bg-blue-400 text-white",
|
||||
info: "bg-gray-400 text-white",
|
||||
}
|
||||
|
||||
const SEV_LABEL: Record<string, string> = {
|
||||
critical: "CRITICAL",
|
||||
high: "HIGH",
|
||||
medium: "MEDIUM",
|
||||
low: "LOW",
|
||||
info: "INFO",
|
||||
}
|
||||
|
||||
function Pill({ severity }: { severity: string }) {
|
||||
return (
|
||||
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${SEV_STYLE[severity] || SEV_STYLE.info}`}>
|
||||
{SEV_LABEL[severity] || severity.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function affectedLine(f: Finding): string {
|
||||
const parts: string[] = []
|
||||
if (f.source_ip) parts.push(`Source ${f.source_ip}`)
|
||||
if (f.destination_ip) parts.push(`Destination ${f.destination_ip}`)
|
||||
if (f.protocol) parts.push(f.protocol + (f.port ? `:${f.port}` : ""))
|
||||
const ev = f.evidence || {}
|
||||
for (const k of ["host", "registered_domain", "query_name", "target"]) {
|
||||
if (ev[k]) { parts.push(String(ev[k])); break }
|
||||
}
|
||||
return parts.join(", ") || "—"
|
||||
}
|
||||
|
||||
// Evidence rendered in dedicated sections is excluded from raw details.
|
||||
const EVIDENCE_SKIP = new Set([
|
||||
"dedup_key", "sources", "artifact", "capture_file", "source_packet_count", "occurrences",
|
||||
])
|
||||
|
||||
type SourceRef = {
|
||||
packet?: number; stream?: number; timestamp?: number
|
||||
src_ip?: string; dst_ip?: string; protocol?: string; port?: number
|
||||
capture?: string; note?: string
|
||||
}
|
||||
|
||||
function fmtTs(ts?: number): string {
|
||||
if (ts === undefined || ts === null) return ""
|
||||
try { return new Date(ts * 1000).toISOString().replace("T", " ").replace(".000Z", " UTC") }
|
||||
catch { return String(ts) }
|
||||
}
|
||||
|
||||
function artifactOf(f: Finding): string | null {
|
||||
const art = (f.evidence || {})["artifact"] as { label?: string; value?: string } | string | undefined
|
||||
if (!art) return null
|
||||
if (typeof art === "string") return art
|
||||
if (art.value) return art.label ? `${art.label}: ${art.value}` : art.value
|
||||
return null
|
||||
}
|
||||
|
||||
function sourcesOf(f: Finding): SourceRef[] {
|
||||
const s = (f.evidence || {})["sources"]
|
||||
return Array.isArray(s) ? (s as SourceRef[]) : []
|
||||
}
|
||||
|
||||
function fmtSource(ref: SourceRef): string {
|
||||
const parts: string[] = []
|
||||
if (ref.packet !== undefined) parts.push(`Packet #${ref.packet}`)
|
||||
if (ref.stream !== undefined) parts.push(`Stream ${ref.stream}`)
|
||||
if (ref.timestamp !== undefined) parts.push(fmtTs(ref.timestamp))
|
||||
if (ref.src_ip || ref.dst_ip) parts.push(`${ref.src_ip ?? "?"} → ${ref.dst_ip ?? "?"}`)
|
||||
if (ref.protocol) parts.push(ref.protocol + (ref.port ? `:${ref.port}` : ""))
|
||||
if (ref.capture) parts.push(`Capture ${ref.capture}`)
|
||||
if (ref.note) parts.push(ref.note)
|
||||
return parts.join(" · ") || "—"
|
||||
}
|
||||
|
||||
function FindingsContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const fileId = searchParams.get("fileId")
|
||||
|
||||
const [data, setData] = useState<AnalysisResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<DeepStatus | null>(null)
|
||||
const [cloud, setCloud] = useState<CloudResults | null>(null)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [selUrls, setSelUrls] = useState<Set<number>>(new Set())
|
||||
const [selHashes, setSelHashes] = useState<Set<number>>(new Set())
|
||||
|
||||
const fetchCloud = async () => {
|
||||
if (!fileId) return
|
||||
try {
|
||||
const res = await fetch(getApiUrl(`get_cloud_results/${fileId}`), {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) setCloud(await res.json())
|
||||
} catch { /* Optional enrichment data does not block the finding view. */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileId) { setError("No capture ID was provided."); setLoading(false); return }
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(getApiUrl(`get_findings/${fileId}`), {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setData(await res.json())
|
||||
} catch (e) {
|
||||
setError(`Findings could not be loaded: ${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
await fetchCloud()
|
||||
try {
|
||||
const sres = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
if (sres.ok) {
|
||||
const s: DeepStatus = await sres.json()
|
||||
setStatus(s)
|
||||
if (s.state === "running") setAnalyzing(true)
|
||||
}
|
||||
} catch { /* Status is optional until enrichment starts. */ }
|
||||
}
|
||||
load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileId])
|
||||
|
||||
// Refresh enrichment results after the user returns to this tab.
|
||||
useEffect(() => {
|
||||
if (!fileId) return
|
||||
const onFocus = () => { fetchCloud() }
|
||||
window.addEventListener("focus", onFocus)
|
||||
document.addEventListener("visibilitychange", onFocus)
|
||||
return () => {
|
||||
window.removeEventListener("focus", onFocus)
|
||||
document.removeEventListener("visibilitychange", onFocus)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileId])
|
||||
|
||||
// Poll only while enrichment is running.
|
||||
useEffect(() => {
|
||||
if (!fileId || !analyzing) return
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) {
|
||||
const s: DeepStatus = await res.json()
|
||||
setStatus(s)
|
||||
if (s.state === "done" || s.state === "error") setAnalyzing(false)
|
||||
}
|
||||
} catch { /* Retry on the next polling interval. */ }
|
||||
await fetchCloud()
|
||||
}
|
||||
poll()
|
||||
timer = setInterval(poll, 3000)
|
||||
return () => { if (timer) clearInterval(timer) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileId, analyzing])
|
||||
|
||||
const startAnalysis = async () => {
|
||||
if (!fileId || (selUrls.size + selHashes.size) === 0) return
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`start_analysis/${fileId}`), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url_ids: Array.from(selUrls), hash_ids: Array.from(selHashes) }),
|
||||
cache: "no-store",
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
setAnalyzing(true)
|
||||
} catch (e) {
|
||||
setError(`Enrichment could not be started: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleId = (set: Set<number>, setter: (s: Set<number>) => void, id: number) => {
|
||||
const next = new Set(set)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
setter(next)
|
||||
}
|
||||
const setAll = (ids: number[], setter: (s: Set<number>) => void, on: boolean) =>
|
||||
setter(on ? new Set(ids) : new Set())
|
||||
|
||||
const download = (fmt: "md" | "txt" | "json") => {
|
||||
if (!fileId) return
|
||||
window.open(getApiUrl(`get_report/${fileId}/${fmt}`), "_blank")
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <CenterCard title="Loading analysis…" desc="Retrieving findings." />
|
||||
}
|
||||
if (error) {
|
||||
return <CenterCard title="Error" desc={error} />
|
||||
}
|
||||
|
||||
const summary = data?.summary
|
||||
const findings = data?.findings ?? []
|
||||
const isEmpty = findings.length === 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 bg-background">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-4 flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => router.push("/devices")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to devices
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={startAnalysis}
|
||||
disabled={analyzing || (selUrls.size + selHashes.size) === 0}
|
||||
>
|
||||
{analyzing
|
||||
? "Enrichment running…"
|
||||
: `Start enrichment (${selUrls.size + selHashes.size})`}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => download("md")}>
|
||||
<FileText className="mr-2 h-4 w-4" /> .md
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => download("txt")}>
|
||||
<FileCode className="mr-2 h-4 w-4" /> .txt
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => download("json")}>
|
||||
<FileJson className="mr-2 h-4 w-4" /> .json
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-3xl flex items-center gap-2">
|
||||
<ShieldAlert className="h-7 w-7" /> Security findings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Network-capture evaluation by IoT PreTester 4.6 Demo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Status of optional URL and hash enrichment. */}
|
||||
{status && status.state !== "pending" && (
|
||||
<div className={`mb-6 rounded-lg border p-4 ${
|
||||
status.state === "done" ? "border-green-300 bg-green-50"
|
||||
: status.state === "error" ? "border-red-300 bg-red-50"
|
||||
: "border-blue-300 bg-blue-50"}`}>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<span className="font-medium">
|
||||
{status.state === "running" && "Artifact enrichment is running…"}
|
||||
{status.state === "done" && "✅ Artifact enrichment completed"}
|
||||
{status.state === "error" && "⚠️ Artifact enrichment failed"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
URLs {status.url_done}/{status.url_total} · Hashes {status.hash_done}/{status.hash_total}
|
||||
{status.cracked > 0 && ` · ${status.cracked} cracked`}
|
||||
</span>
|
||||
</div>
|
||||
{status.state === "running" && status.progress_total > 0 && (
|
||||
<div className="mt-2 h-2 w-full rounded bg-blue-100 overflow-hidden">
|
||||
<div className="h-full bg-blue-500 transition-all" style={{ width: `${status.percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{(status.state === "done" || status.state === "running") && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Details are available under “Cloud URLs and hashes”.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Severity summary */}
|
||||
{summary && (
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
{SEV_ORDER.map((sev) => (
|
||||
<div key={sev} className="flex items-center gap-2">
|
||||
<Pill severity={sev} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{summary.by_severity?.[sev] ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-sm text-muted-foreground ml-auto">
|
||||
Total: <span className="font-semibold">{summary.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prioritized highlights */}
|
||||
{summary && summary.highlights?.length > 0 && (
|
||||
<div className="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
|
||||
<h3 className="font-semibold mb-2 text-red-800">⚠️ Prioritized highlights</h3>
|
||||
<ul className="space-y-1">
|
||||
{summary.highlights.map((f) => (
|
||||
<li key={f.finding_id} className="text-sm">
|
||||
<Pill severity={f.severity} /> <span className="ml-1">{f.title}</span>
|
||||
<span className="text-muted-foreground"> — {affectedLine(f)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No security-relevant findings were detected, or analysis is still running.
|
||||
<br />Reload the page if the capture has just been submitted.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{findings.map((f, i) => {
|
||||
const artifact = artifactOf(f)
|
||||
const sources = sourcesOf(f)
|
||||
const pktTotal = typeof f.evidence?.source_packet_count === "number"
|
||||
? (f.evidence.source_packet_count as number) : 0
|
||||
const captureFile = typeof f.evidence?.capture_file === "string"
|
||||
? (f.evidence.capture_file as string) : null
|
||||
return (
|
||||
<Card key={f.finding_id} className="border-l-4" style={{ borderLeftColor: sevBorder(f.severity) }}>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h4 className="font-semibold">{i + 1}. {f.title}</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill severity={f.severity} />
|
||||
<span className="text-xs text-muted-foreground">Confidence: {f.confidence}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Primary artifact */}
|
||||
{artifact && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-2">
|
||||
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">🔎 Observed artifact</div>
|
||||
<code className="text-sm break-all">{artifact}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packet-level sources */}
|
||||
{sources.length > 0 && (
|
||||
<div className="rounded-md border bg-muted/40 p-2">
|
||||
<div className="text-xs font-medium mb-1">📍 Capture sources</div>
|
||||
<ul className="space-y-0.5">
|
||||
{sources.map((ref, idx) => (
|
||||
<li key={idx} className="text-xs font-mono text-muted-foreground break-all">
|
||||
{fmtSource(ref)}
|
||||
</li>
|
||||
))}
|
||||
{pktTotal > sources.length && (
|
||||
<li className="text-xs text-muted-foreground">
|
||||
… and {pktTotal - sources.length} more packets
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm"><span className="font-medium">Risk:</span> {f.risk_description}</p>
|
||||
<p className="text-sm"><span className="font-medium">Affected:</span> {affectedLine(f)}</p>
|
||||
<p className="text-sm"><span className="font-medium">Recommendation:</span> {f.recommendation}</p>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Category: {f.category} · Detector: {f.detector}
|
||||
{captureFile && <> · Capture: {captureFile}</>}
|
||||
</div>
|
||||
{f.evidence && Object.keys(f.evidence).filter((k) => !EVIDENCE_SKIP.has(k)).length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground">Additional evidence</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap bg-muted p-2 rounded">
|
||||
{JSON.stringify(
|
||||
Object.fromEntries(Object.entries(f.evidence).filter(([k]) => !EVIDENCE_SKIP.has(k))),
|
||||
null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cloud && (cloud.hashes.length > 0 || cloud.urls.length > 0) && (
|
||||
<h2 className="text-2xl font-bold mt-10 mb-3">Extracted artifacts</h2>
|
||||
)}
|
||||
|
||||
{/* Hash artifacts */}
|
||||
{cloud && cloud.hashes.length > 0 && (
|
||||
<details className="mt-2 group">
|
||||
<summary className="flex items-center justify-between gap-2 mb-3 cursor-pointer list-none">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<ChevronRight className="h-5 w-5 transition-transform group-open:rotate-90" />
|
||||
Hashes ({cloud.hashes.length})
|
||||
</h3>
|
||||
<label className="text-sm flex items-center gap-1 cursor-pointer text-muted-foreground"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" disabled={analyzing}
|
||||
checked={cloud.hashes.every((h) => selHashes.has(h.id))}
|
||||
onChange={(e) => setAll(cloud.hashes.map((h) => h.id), setSelHashes, e.target.checked)} />
|
||||
select all
|
||||
</label>
|
||||
</summary>
|
||||
<div className="space-y-3">
|
||||
{cloud.hashes.map((h) => (
|
||||
<Card key={h.id} className="border-l-4"
|
||||
style={{ borderLeftColor: h.cracked ? "#dc2626" : "#9ca3af" }}>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h4 className="font-semibold flex items-center gap-2 min-w-0">
|
||||
<input type="checkbox" disabled={analyzing}
|
||||
checked={selHashes.has(h.id)}
|
||||
onChange={() => toggleId(selHashes, setSelHashes, h.id)} />
|
||||
<code className="font-mono text-sm break-all">{h.hash_value}</code>
|
||||
</h4>
|
||||
<div className="shrink-0">
|
||||
{h.cracked
|
||||
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-red-600 text-white">CRACKED</span>
|
||||
: h.analyzed
|
||||
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-gray-400 text-white">not cracked</span>
|
||||
: (analyzing && selHashes.has(h.id))
|
||||
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-blue-400 text-white">running…</span>
|
||||
: <span className="rounded px-2 py-0.5 text-xs font-semibold bg-gray-200 text-gray-700">not analyzed</span>}
|
||||
</div>
|
||||
</div>
|
||||
{h.hash_type && h.hash_type.length > 0 && (
|
||||
<p className="text-sm"><span className="font-medium">Type:</span> {h.hash_type.join(", ")}</p>
|
||||
)}
|
||||
{h.cracked && (
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Plaintext:</span>{" "}
|
||||
<code className="bg-red-100 px-1 rounded">{h.cracked_value}</code>
|
||||
{h.crack_time ? ` (${h.crack_time})` : ""}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Endpoint artifacts */}
|
||||
{cloud && cloud.urls.length > 0 && (
|
||||
<details className="mt-3 group">
|
||||
<summary className="flex items-center justify-between gap-2 mb-3 cursor-pointer list-none">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<ChevronRight className="h-5 w-5 transition-transform group-open:rotate-90" />
|
||||
<Globe className="h-5 w-5" /> Cloud URLs and endpoints ({cloud.urls.length})
|
||||
</h3>
|
||||
<label className="text-sm flex items-center gap-1 cursor-pointer text-muted-foreground"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" disabled={analyzing}
|
||||
checked={cloud.urls.every((u) => selUrls.has(u.id))}
|
||||
onChange={(e) => setAll(cloud.urls.map((u) => u.id), setSelUrls, e.target.checked)} />
|
||||
select all
|
||||
</label>
|
||||
</summary>
|
||||
<div className="space-y-3">
|
||||
{cloud.urls.map((u) => (
|
||||
<Card key={u.id} className="border-l-4" style={{ borderLeftColor: "#60a5fa" }}>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h4 className="font-semibold flex items-center gap-2 min-w-0">
|
||||
<input type="checkbox" disabled={analyzing}
|
||||
checked={selUrls.has(u.id)}
|
||||
onChange={() => toggleId(selUrls, setSelUrls, u.id)} />
|
||||
<code className="font-mono text-sm break-all">{u.url}</code>
|
||||
</h4>
|
||||
<div className="shrink-0">
|
||||
{u.analyzed
|
||||
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-green-600 text-white">analyzed</span>
|
||||
: (analyzing && selUrls.has(u.id))
|
||||
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-blue-400 text-white">running…</span>
|
||||
: <span className="rounded px-2 py-0.5 text-xs font-semibold bg-gray-200 text-gray-700">not analyzed</span>}
|
||||
</div>
|
||||
</div>
|
||||
{u.analyzed && (
|
||||
<>
|
||||
<p className="text-sm"><span className="font-medium">Open ports:</span> {u.ports && u.ports.length > 0 ? u.ports.join(", ") : "—"}</p>
|
||||
{u.tls_versions && u.tls_versions.length > 0 && <p className="text-sm"><span className="font-medium">TLS:</span> {u.tls_versions.join(", ")}</p>}
|
||||
{u.cipher_suites && u.cipher_suites.length > 0 && <p className="text-sm"><span className="font-medium">Cipher:</span> {u.cipher_suites.join(", ")}</p>}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sevBorder(sev: string): string {
|
||||
return ({
|
||||
critical: "#dc2626", high: "#f97316", medium: "#facc15", low: "#60a5fa", info: "#9ca3af",
|
||||
} as Record<string, string>)[sev] || "#9ca3af"
|
||||
}
|
||||
|
||||
function CenterCard({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
|
||||
<Card className="w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{desc}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FindingsPage() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Suspense fallback={<CenterCard title="Loading…" desc="Please wait." />}>
|
||||
<FindingsContent />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222.2 47.4% 11.2%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 47.4% 11.2%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 47.4% 11.2%;
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 214 32% 91%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 217 91% 95%;
|
||||
--accent-foreground: 221 83% 45%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214.3 31.8% 88%;
|
||||
--input: 214.3 31.8% 86%;
|
||||
--ring: 221 83% 53%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3%;
|
||||
--foreground: 0 0% 0%;
|
||||
--card: 0 0% 5%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import "./globals.css"
|
||||
import localFont from "next/font/local"
|
||||
import { Providers } from "@/components/providers"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
|
||||
const geist = localFont({ src: "./fonts/GeistVF.woff" })
|
||||
|
||||
export const metadata = {
|
||||
title: "IoT PreTester 4.6 Demo",
|
||||
description: "Partner demo for IoT network-capture security analysis",
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={geist.className}>
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster />
|
||||
<div className="fixed bottom-2 right-3 z-50 pointer-events-none select-none text-xs text-muted-foreground/80">
|
||||
4.6 Demo
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
import { getApiUrl } from "@/config/api"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { AnimatedBackground } from "@/components/ui/animated-background"
|
||||
import Image from "next/image"
|
||||
|
||||
const LoginPage = () => {
|
||||
const router = useRouter()
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl("login"), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
const [success, data] = await response.json()
|
||||
|
||||
if (success) {
|
||||
login(data.user)
|
||||
router.push("/devices")
|
||||
} else {
|
||||
setError(data.message || "Invalid username or password")
|
||||
}
|
||||
} catch {
|
||||
setError("An error occurred during login")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-background relative overflow-hidden">
|
||||
<AnimatedBackground />
|
||||
<div className="w-full max-w-sm mb-4 relative z-10 transition-all duration-500 hover:scale-105">
|
||||
<Image
|
||||
src="/images/logos/Logo.png"
|
||||
alt="IoT PreTester 4.6 Demo logo"
|
||||
width={224}
|
||||
height={224}
|
||||
className="block mx-auto drop-shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
<Card className="w-full max-w-sm relative z-10 backdrop-blur-sm bg-card/80 border-primary/20 shadow-xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">Login</CardTitle>
|
||||
<CardDescription>Enter your credentials to access the system</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your username"
|
||||
className="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your password"
|
||||
className="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="mt-4 text-sm text-muted-foreground relative z-10">4.6 Demo</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
import { getApiUrl } from "@/config/api"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
import axios from "axios"
|
||||
|
||||
interface DeviceType {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function ManageDeviceTypesPage() {
|
||||
const router = useRouter()
|
||||
const { user } = useAuth()
|
||||
const [deviceTypes, setDeviceTypes] = useState<DeviceType[]>([])
|
||||
const [newTypeLabel, setNewTypeLabel] = useState("")
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [typeToDelete, setTypeToDelete] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "admin" && user?.role !== "superuser") {
|
||||
router.push("/")
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
const fetchDeviceTypes = async () => {
|
||||
try {
|
||||
const response = await axios.get(getApiUrl("get_all_devicetypes"))
|
||||
const formattedDeviceTypes = response.data.map((item: { id: number; device_type: string }) => ({
|
||||
value: item.id,
|
||||
label: item.device_type,
|
||||
}))
|
||||
setDeviceTypes(formattedDeviceTypes)
|
||||
} catch (error) {
|
||||
console.error("Error fetching device types:", error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeviceTypes()
|
||||
}, [])
|
||||
|
||||
const addDeviceType = async () => {
|
||||
if (newTypeLabel.trim()) {
|
||||
try {
|
||||
await axios.post(getApiUrl("create_device_type"), { device_type: newTypeLabel.trim() })
|
||||
setNewTypeLabel("")
|
||||
fetchDeviceTypes()
|
||||
} catch (error) {
|
||||
console.error("Error adding device type:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteClick = (value: number) => {
|
||||
setTypeToDelete(value)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (typeToDelete !== null) {
|
||||
try {
|
||||
await axios.delete(getApiUrl(`delete_device_type/${typeToDelete}`))
|
||||
fetchDeviceTypes()
|
||||
setDeleteDialogOpen(false)
|
||||
setTypeToDelete(null)
|
||||
} catch (error) {
|
||||
console.error("Error deleting device type:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen p-4 bg-background">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">Manage Device Types</CardTitle>
|
||||
<CardDescription>
|
||||
Add or remove device types that users can select when adding new devices
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-grow">
|
||||
<Label htmlFor="typeLabel">Type Name</Label>
|
||||
<Input
|
||||
id="typeLabel"
|
||||
value={newTypeLabel}
|
||||
onChange={(e) => setNewTypeLabel(e.target.value)}
|
||||
placeholder="Enter device type name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={addDeviceType}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Type
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Available Device Types</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Display Name</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deviceTypes.map((type) => (
|
||||
<TableRow key={type.value}>
|
||||
<TableCell>{type.label}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{type.value}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(type.value)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button onClick={() => router.push("/devices")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Devices
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">Delete Device Type</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this device type? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
import { getApiUrl } from "@/config/api"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
import axios from "axios"
|
||||
|
||||
interface Manufacturer {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function ManageManufacturersPage() {
|
||||
const router = useRouter()
|
||||
const { user } = useAuth()
|
||||
const [manufacturers, setManufacturers] = useState<Manufacturer[]>([])
|
||||
const [newManufacturerLabel, setNewManufacturerLabel] = useState("")
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [manufacturerToDelete, setManufacturerToDelete] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "superuser" && user?.role !== "admin") {
|
||||
router.push("/")
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
const fetchManufacturers = async () => {
|
||||
try {
|
||||
const response = await axios.get(getApiUrl("get_all_manufacturers"))
|
||||
const formattedManufacturers = response.data.map((item: { id: number; name: string }) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))
|
||||
setManufacturers(formattedManufacturers)
|
||||
} catch (error) {
|
||||
console.error("Error fetching manufacturers:", error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchManufacturers()
|
||||
}, [])
|
||||
|
||||
const addManufacturer = async () => {
|
||||
if (newManufacturerLabel.trim()) {
|
||||
try {
|
||||
await axios.post(getApiUrl("create_manufact"), { name: newManufacturerLabel.trim() })
|
||||
setNewManufacturerLabel("")
|
||||
fetchManufacturers()
|
||||
} catch (error) {
|
||||
console.error("Error adding manufacturer:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteClick = (value: number) => {
|
||||
setManufacturerToDelete(value)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (manufacturerToDelete !== null) {
|
||||
try {
|
||||
await axios.delete(getApiUrl(`delete_manufacturer/${manufacturerToDelete}`))
|
||||
fetchManufacturers()
|
||||
setDeleteDialogOpen(false)
|
||||
setManufacturerToDelete(null)
|
||||
} catch (error) {
|
||||
console.error("Error deleting manufacturer:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen p-4 bg-background">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">Manage Manufacturers</CardTitle>
|
||||
<CardDescription>
|
||||
Add or remove manufacturers that users can select when adding new devices
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-grow">
|
||||
<Label htmlFor="manufacturerLabel">Manufacturer Name</Label>
|
||||
<Input
|
||||
id="manufacturerLabel"
|
||||
value={newManufacturerLabel}
|
||||
onChange={(e) => setNewManufacturerLabel(e.target.value)}
|
||||
placeholder="Enter manufacturer name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={addManufacturer}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Manufacturer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Available Manufacturers</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Display Name</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{manufacturers.map((manufacturer) => (
|
||||
<TableRow key={manufacturer.value}>
|
||||
<TableCell>{manufacturer.label}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{manufacturer.value}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(manufacturer.value)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button onClick={() => router.push("/devices")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Devices
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">Delete Manufacturer</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this manufacturer? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
"use client"
|
||||
import { getApiUrl } from "@/config/api"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import axios from "axios"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import { ArrowLeft, Copy } from "lucide-react"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
const length = 12
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||
const values = crypto.getRandomValues(new Uint32Array(length))
|
||||
return Array.from(values, (value) => charset[value % charset.length]).join("")
|
||||
}
|
||||
|
||||
export default function ManageUsersPage() {
|
||||
const router = useRouter()
|
||||
const { user } = useAuth()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [newUsername, setNewUsername] = useState("")
|
||||
const [newRole, setNewRole] = useState("user")
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [userToDelete, setUserToDelete] = useState<number | null>(null)
|
||||
const [generatedPassword, setGeneratedPassword] = useState<string | null>(null)
|
||||
const [showPasswordAlert, setShowPasswordAlert] = useState(false)
|
||||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
|
||||
const [userToResetPassword, setUserToResetPassword] = useState<number | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "admin" && user?.role !== "superuser") {
|
||||
router.push("/")
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await axios.get(getApiUrl("list-users"))
|
||||
if (response.status === 200 && response.data) {
|
||||
setUsers(response.data.data)
|
||||
} else {
|
||||
console.error("Failed to fetch users")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
const addUser = async () => {
|
||||
if (newUsername) {
|
||||
const password = generatePassword()
|
||||
try {
|
||||
const response = await axios.post(getApiUrl("register"), {
|
||||
username: newUsername,
|
||||
role: newRole,
|
||||
password: password,
|
||||
})
|
||||
if (response.data.status === "success") {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await axios.get(getApiUrl("list-users"))
|
||||
if (response.status === 200 && response.data) {
|
||||
setUsers(response.data.data)
|
||||
} else {
|
||||
console.error("Failed to fetch users")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error)
|
||||
}
|
||||
}
|
||||
await fetchUsers()
|
||||
setNewUsername("")
|
||||
setNewRole("user")
|
||||
setGeneratedPassword(password)
|
||||
setShowPasswordAlert(true)
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "User created successfully",
|
||||
duration: 2000,
|
||||
})
|
||||
} else {
|
||||
console.error("Failed to create user")
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to create user",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error)
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "An error occurred while creating the user",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
}
|
||||
setTimeout(() => setShowPasswordAlert(false), 30000)
|
||||
}
|
||||
|
||||
const handleDeleteClick = (id: number) => {
|
||||
setUserToDelete(id)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (userToDelete) {
|
||||
try {
|
||||
const response = await axios.post(getApiUrl("delete-user"), { id: userToDelete })
|
||||
if (response.data.status === "success") {
|
||||
setUsers(users.filter((user) => user.id !== userToDelete))
|
||||
setDeleteDialogOpen(false)
|
||||
setUserToDelete(null)
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "User deleted successfully",
|
||||
duration: 2000,
|
||||
})
|
||||
} else {
|
||||
console.error("Failed to delete user")
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete user",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error)
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "An error occurred while deleting the user",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast({
|
||||
title: "Copied!",
|
||||
description: "Password copied to clipboard",
|
||||
duration: 2000,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err)
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to copy password",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetPasswordClick = (userId: number) => {
|
||||
setUserToResetPassword(userId)
|
||||
setResetPasswordDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmResetPassword = async () => {
|
||||
if (userToResetPassword) {
|
||||
const newPassword = generatePassword()
|
||||
try {
|
||||
const response = await axios.post(getApiUrl("reset-password"), {
|
||||
user_id: userToResetPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
if (response.data.status === "success") {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await axios.get(getApiUrl("list-users"))
|
||||
if (response.status === 200 && response.data) {
|
||||
setUsers(response.data.data)
|
||||
} else {
|
||||
console.error("Failed to fetch users")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error)
|
||||
}
|
||||
}
|
||||
await fetchUsers()
|
||||
setGeneratedPassword(newPassword)
|
||||
setShowPasswordAlert(true)
|
||||
setResetPasswordDialogOpen(false)
|
||||
setUserToResetPassword(null)
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Password reset successfully",
|
||||
duration: 2000,
|
||||
})
|
||||
} else {
|
||||
console.error("Failed to reset password")
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to reset password",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error resetting password:", error)
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "An error occurred while resetting the password",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
}
|
||||
setTimeout(() => setShowPasswordAlert(false), 30000)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen p-4 bg-background">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">Manage Users</CardTitle>
|
||||
<CardDescription>Add, view, or remove users from the system</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Add New User</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[2fr,1fr,auto] gap-4">
|
||||
<div>
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<select
|
||||
id="role"
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
className="w-full p-2 rounded-md border border-input bg-background"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={addUser}>Add User</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPasswordAlert && generatedPassword && (
|
||||
<Alert className="bg-yellow-500/20 text-yellow-200 border-yellow-500">
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold">Initial password: </span>
|
||||
<code className="bg-yellow-500/20 px-2 py-1 rounded">{generatedPassword}</code>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => copyToClipboard(generatedPassword)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Existing Users</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.role}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleResetPasswordClick(user.id)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick(user.id)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button onClick={() => router.push("/devices")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Devices
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">Delete User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this user? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reset Password Confirmation Dialog */}
|
||||
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">Reset Password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to reset this user's password? Share the new initial password securely.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setResetPasswordDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="default" onClick={confirmResetPassword}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
redirect('/devices')
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
|
||||
|
||||
// The legacy route forwards directly to the consolidated findings view.
|
||||
const ROUTE_FINDINGS = "/findings"
|
||||
|
||||
function SmartResultsRedirectContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [status, setStatus] = useState<"idle" | "checking" | "redirected" | "error">("idle")
|
||||
|
||||
useEffect(() => {
|
||||
const fileId = searchParams.get("fileId")
|
||||
if (!fileId) {
|
||||
setStatus("error")
|
||||
return
|
||||
}
|
||||
setStatus("checking")
|
||||
router.replace(`${ROUTE_FINDINGS}?fileId=${encodeURIComponent(fileId)}`)
|
||||
setStatus("redirected")
|
||||
}, [router, searchParams])
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
|
||||
<Card className="w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{status === "error" ? "Error" : "Loading..."}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status === "error"
|
||||
? "Couldn't determine result page. Please try again."
|
||||
: "Determining the correct result page…"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SmartResultsRedirectPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
|
||||
<Card className="w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>Loading...</CardTitle>
|
||||
<CardDescription>Please wait...</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<SmartResultsRedirectContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const getDeviceImage = (deviceType: string) => {
|
||||
switch (deviceType.toLowerCase()) {
|
||||
case 'camera':
|
||||
return '/images/devices/camera.png';
|
||||
case 'smart-plug':
|
||||
return '/images/devices/smart_plug.png';
|
||||
case 'smart-bulb':
|
||||
return '/images/devices/smart_bulb.png';
|
||||
case 'smart-switch':
|
||||
return '/images/devices/smart_switch.png';
|
||||
case 'robot-vacuum':
|
||||
case 'robot vacuum cleaner':
|
||||
return '/images/devices/robot_vacuum_cleaner.png';
|
||||
case 'smart-lock':
|
||||
return '/images/devices/smart_lock.png';
|
||||
case 'smart-controller':
|
||||
return '/images/devices/smart_controller.png';
|
||||
case 'sensor':
|
||||
return '/images/devices/sensor.png';
|
||||
default:
|
||||
return '/images/logos/Placeholder.png';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client"
|
||||
import { getApiUrl } from "@/config/api"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import axios from "axios"
|
||||
import { FileIcon, UploadCloudIcon, XIcon, ScanIcon, ArrowLeft } from "lucide-react"
|
||||
import { useDropzone } from "react-dropzone"
|
||||
import type { FileRejection } from "react-dropzone"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
interface FileUploadProps {
|
||||
onBack?: () => void
|
||||
deviceId?: string
|
||||
}
|
||||
|
||||
interface FileDetails {
|
||||
firmware: string
|
||||
ipAddress: string
|
||||
}
|
||||
|
||||
export function FileUpload({ onBack, deviceId }: FileUploadProps) {
|
||||
const router = useRouter()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const [fileDetails, setFileDetails] = useState<FileDetails>({
|
||||
firmware: "",
|
||||
ipAddress: "",
|
||||
})
|
||||
|
||||
const onDrop = React.useCallback((acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
||||
if (fileRejections.length > 0) {
|
||||
setError("Invalid file type. Please upload a .pcap or .pcapng capture.")
|
||||
return
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > 0) {
|
||||
const selectedFile = acceptedFiles[0]
|
||||
if (selectedFile.size > 512 * 1024 * 1024) {
|
||||
setError("File size exceeds the 512 MB demo limit.")
|
||||
return
|
||||
}
|
||||
setFile(selectedFile)
|
||||
setError(null)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"application/vnd.tcpdump.pcap": [".pcap"],
|
||||
"application/x-pcapng": [".pcapng"],
|
||||
},
|
||||
maxFiles: 1,
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
const removeFile = () => {
|
||||
setFile(null)
|
||||
setUploadProgress(0)
|
||||
setError(null)
|
||||
setFileDetails({
|
||||
firmware: "",
|
||||
ipAddress: "",
|
||||
})
|
||||
}
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!file || !fileDetails.firmware || !fileDetails.ipAddress) return
|
||||
|
||||
try {
|
||||
setIsScanning(true)
|
||||
setError(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("capture", file)
|
||||
formData.append("device_id", deviceId || "")
|
||||
formData.append("firmware_version", fileDetails.firmware)
|
||||
formData.append("ip_address", fileDetails.ipAddress)
|
||||
|
||||
const response = await axios.post(getApiUrl("captures"), formData, {
|
||||
onUploadProgress: (event) => {
|
||||
if (event.total) setUploadProgress(Math.round((event.loaded / event.total) * 100))
|
||||
},
|
||||
})
|
||||
|
||||
const fileId = response.data.data.id
|
||||
router.push(`/scan-results?fileId=${fileId}`)
|
||||
} catch (err) {
|
||||
console.error("Scan error:", err)
|
||||
const apiError = axios.isAxiosError(err)
|
||||
? err.response?.data?.error || err.response?.data?.capture?.[0] || err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
setError(`Failed to scan file: ${apiError}`)
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFileDetails((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{onBack && (
|
||||
<Button onClick={onBack} className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<CardTitle className="text-2xl font-bold">Upload New File</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Upload a PCAP/PCAPNG capture for IoT network security analysis.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{!file && (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors
|
||||
${isDragActive ? "border-primary bg-primary/5" : "border-border"}
|
||||
${error ? "border-destructive bg-destructive/5" : ""}
|
||||
`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center text-center space-y-4">
|
||||
<UploadCloudIcon className="w-12 h-12 text-muted-foreground" />
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Drag & drop your file here</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
or click to select a file from your computer
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Supported formats: .pcap and .pcapng · maximum 512 MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{file && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<FileIcon className="w-5 h-5 text-primary" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Network capture
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={removeFile}>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{isScanning && <Progress value={uploadProgress} className="mt-4" />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firmware">Firmware Version</Label>
|
||||
<Input
|
||||
id="firmware"
|
||||
name="firmware"
|
||||
value={fileDetails.firmware}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter firmware version"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ipAddress">IP Address</Label>
|
||||
<Input
|
||||
id="ipAddress"
|
||||
name="ipAddress"
|
||||
value={fileDetails.ipAddress}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter IP address"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full py-6 text-lg"
|
||||
onClick={handleScan}
|
||||
disabled={isScanning || !fileDetails.firmware || !fileDetails.ipAddress}
|
||||
>
|
||||
{isScanning ? (
|
||||
<>
|
||||
<ScanIcon className="mr-2 h-5 w-5 animate-spin" />
|
||||
Scanning capture...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScanIcon className="mr-2 h-5 w-5" />
|
||||
Analyze Capture
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AuthUser, useAuth } from "@/lib/auth-context"
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode | ((props: { user: AuthUser; logout: () => void }) => ReactNode)
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { user, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) router.replace("/login")
|
||||
}, [user, router])
|
||||
|
||||
if (!user) return null
|
||||
return typeof children === "function" ? children({ user, logout }) : children
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { AuthProvider } from '@/lib/auth-context'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <AuthProvider>{children}</AuthProvider>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
export function AnimatedBackground() {
|
||||
return (
|
||||
<>
|
||||
{/* Floating particles */}
|
||||
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute h-2 w-2 rounded-full bg-primary/30"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
animation: `float ${5 + Math.random() * 10}s ease-in-out infinite`,
|
||||
animationDelay: `${Math.random() * 5}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CSS Animations */}
|
||||
<style jsx>{`
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) translateX(10px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-primary/40",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
export type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
export type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = "ADD_TOAST" | "UPDATE_TOAST" | "DISMISS_TOAST" | "REMOVE_TOAST"
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: Extract<ActionType, "ADD_TOAST">
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: Extract<ActionType, "UPDATE_TOAST">
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: Extract<ActionType, "DISMISS_TOAST">
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: Extract<ActionType, "REMOVE_TOAST">
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<ToasterToast[]>([])
|
||||
|
||||
return {
|
||||
toasts,
|
||||
toast: (props: Omit<ToasterToast, "id">) => {
|
||||
const id = genId()
|
||||
setToasts((toasts) => [
|
||||
...toasts,
|
||||
{
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) {
|
||||
setToasts((toasts) => toasts.filter((t) => t.id !== id))
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
},
|
||||
dismiss: (id: string) => {
|
||||
setToasts((toasts) => toasts.filter((t) => t.id !== id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import axios from "axios"
|
||||
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"
|
||||
|
||||
// The Django API uses an authenticated session shared across localhost ports.
|
||||
axios.defaults.withCredentials = true
|
||||
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
const path = endpoint.replace(/^\/+/, "")
|
||||
return `${API_BASE_URL.replace(/\/+$/, "")}/${path}`
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
export type AuthUser = {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
|
||||
type AuthContextType = {
|
||||
user: AuthUser | null
|
||||
login: (user: AuthUser) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem("user")
|
||||
if (!storedUser) return
|
||||
try {
|
||||
setUser(JSON.parse(storedUser))
|
||||
} catch {
|
||||
localStorage.removeItem("user")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = (userData: AuthUser) => {
|
||||
setUser(userData)
|
||||
localStorage.setItem("user", JSON.stringify(userData))
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
setUser(null)
|
||||
localStorage.removeItem("user")
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={{ user, login, logout }}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import axios from "axios"
|
||||
import type { Device } from "@/types/device"
|
||||
import { getApiUrl } from "@/config/api"
|
||||
|
||||
interface ApiDevice {
|
||||
id: number
|
||||
device_name: string
|
||||
device_type: string
|
||||
manufacturer_name: string
|
||||
serial: string
|
||||
comment: string
|
||||
created: string
|
||||
}
|
||||
|
||||
interface ApiCapture {
|
||||
id: number
|
||||
filename: string
|
||||
upload: string
|
||||
firmware_version: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
interface ApiManufacturer {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ApiDeviceType {
|
||||
id: number
|
||||
device_type: string
|
||||
}
|
||||
|
||||
export const getAllDevices = async (): Promise<Device[]> => {
|
||||
try {
|
||||
const response = await axios.get(getApiUrl("get_all_devices"))
|
||||
const devices = await Promise.all(
|
||||
response.data.map(async (device: ApiDevice) => {
|
||||
const filesResponse = await axios.get(getApiUrl(`get_file_by_device/${device.id}`))
|
||||
const filesData: ApiCapture[] = filesResponse.data
|
||||
return {
|
||||
id: String(device.id),
|
||||
name: device.device_name,
|
||||
type: device.device_type,
|
||||
manufacturer: device.manufacturer_name,
|
||||
serialNumber: device.serial,
|
||||
comment: device.comment,
|
||||
ipAddress: "",
|
||||
lastScanned: new Date(device.created).toLocaleDateString("en-GB"),
|
||||
files: filesData.map((file) => ({
|
||||
id: String(file.id),
|
||||
name: file.filename,
|
||||
uploadedAt: file.upload,
|
||||
status: "scanned" as const,
|
||||
firmwareVersion: file.firmware_version,
|
||||
ipAddress: file.ip,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
)
|
||||
return devices
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch devices:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const getManufacturers = async () => {
|
||||
try {
|
||||
const response = await axios.get(getApiUrl("get_all_manufacturers"))
|
||||
return response.data.map((type: ApiManufacturer) => ({
|
||||
value: String(type.id),
|
||||
label: type.name,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch manufacturers:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const getDeviceTypes = async () => {
|
||||
try {
|
||||
const response = await axios.get(getApiUrl("get_all_devicetypes"))
|
||||
return response.data.map((type: ApiDeviceType) => ({
|
||||
value: String(type.id),
|
||||
label: type.device_type,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch device types:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const addDevice = async (newDevice: {
|
||||
device_name: string
|
||||
device_type_id: string
|
||||
manufacturer_id: string
|
||||
serial: string
|
||||
comment: string
|
||||
}) => {
|
||||
try {
|
||||
await axios.post(getApiUrl("create_device"), newDevice)
|
||||
} catch (error) {
|
||||
console.error("Failed to add device:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteDevice = async (deviceId: string) => {
|
||||
try {
|
||||
await axios.delete(getApiUrl(`delete_device/${deviceId}`))
|
||||
} catch (error) {
|
||||
console.error("Failed to delete device:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface Device {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
manufacturer: string
|
||||
serialNumber: string
|
||||
ipAddress?: string
|
||||
comment?: string
|
||||
lastScanned?: string
|
||||
files?: DeviceFile[]
|
||||
}
|
||||
|
||||
export interface DeviceFile {
|
||||
id: string
|
||||
name: string
|
||||
uploadedAt: string
|
||||
status: "scanned" | "pending"
|
||||
firmwareVersion?: string
|
||||
ipAddress?: string
|
||||
}
|
||||
Reference in New Issue
Block a user