build 4.6 Demo

This commit is contained in:
DaStra3003
2026-08-19 08:04:59 +02:00
commit ff8f1ca989
123 changed files with 17696 additions and 0 deletions
+196
View File
@@ -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>
)
}
+382
View File
@@ -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

+614
View File
@@ -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.
+72
View File
@@ -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;
}
}
+25
View File
@@ -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>
)
}
+114
View File
@@ -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>
)
}
+390
View File
@@ -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&apos;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>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/devices')
}
+65
View File
@@ -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>
)
}
+23
View File
@@ -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';
}
};
+237
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
'use client'
import { AuthProvider } from '@/lib/auth-context'
export function Providers({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>
}
+59
View File
@@ -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>
</>
)
}
+56
View File
@@ -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 }
+79
View File
@@ -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 }
+122
View File
@@ -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,
}
+22
View File
@@ -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 }
+26
View File
@@ -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 }
+28
View File
@@ -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 }
+160
View File
@@ -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,
}
+117
View File
@@ -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,
}
+126
View File
@@ -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,
}
+35
View File
@@ -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>
)
}
+162
View File
@@ -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))
}
}
}
+11
View File
@@ -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}`
}
+49
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+115
View File
@@ -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
}
}
+20
View File
@@ -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
}