v4.5 Demo initial commit

This commit is contained in:
DaStra3003
2026-06-24 14:53:14 +02:00
commit 3de8ded7c2
110 changed files with 14363112 additions and 0 deletions
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,20 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "1gb",
},
},
eslint: {
// Disable ESLint during production builds (fix existing code issues later)
ignoreDuringBuilds: true,
},
typescript: {
// Disable TypeScript errors during builds (fix existing code issues later)
ignoreBuildErrors: true,
},
// Use standalone output for Docker deployment
output: 'standalone',
}
module.exports = nextConfig
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
{
"name": "iot-pre-tester-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.5",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"next": "^15.1.6",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20.17.16",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"eslint": "^8",
"eslint-config-next": "15.0.4",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1,122 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { ArrowLeft, Download, FileJson } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface AnalysisResults {
urls: {
item: string;
results: {
curl?: string;
wget?: string;
host?: string;
sslscan?: string;
};
}[];
hashes: {
item: string;
results: {
hashcat?: string;
};
}[];
}
export default function AnalysisPage() {
const router = useRouter()
const [results, setResults] = useState<AnalysisResults | null>(null)
useEffect(() => {
// Get selected items and their analysis results from sessionStorage
const analysisData = sessionStorage.getItem('analysisResults')
if (analysisData) {
setResults(JSON.parse(analysisData))
} else {
router.push('/scan-results')
}
}, [router])
const handleExportPDF = () => {
// Mock PDF export
alert('PDF export initiated')
}
const handleExportJSON = () => {
if (results) {
const dataStr = JSON.stringify(results, null, 2)
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr)
const exportFileDefaultName = 'analysis-results.json'
const linkElement = document.createElement('a')
linkElement.setAttribute('href', dataUri)
linkElement.setAttribute('download', exportFileDefaultName)
linkElement.click()
}
}
if (!results) {
return null
}
return (
<ProtectedRoute>
<div className="min-h-screen p-4 bg-background">
<div className="max-w-7xl mx-auto space-y-8">
<Card>
<CardHeader>
<div className="flex justify-between items-start mb-4">
<Button
onClick={() => router.push('/scan-results')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Scan Results
</Button>
<div className="flex gap-2">
<Button
onClick={handleExportPDF}
>
<Download className="mr-2 h-4 w-4" />
Export PDF
</Button>
<Button
onClick={handleExportJSON}
>
<FileJson className="mr-2 h-4 w-4" />
Export JSON
</Button>
</div>
</div>
<CardTitle className="text-3xl">Analysis Results</CardTitle>
<CardDescription>
Detailed analysis of selected items using various security tools
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="dashboard" className="space-y-4">
<TabsList>
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
<TabsTrigger value="raw">Raw Results</TabsTrigger>
</TabsList>
<TabsContent value="dashboard">
</TabsContent>
<TabsContent value="raw">
<Card>
<CardContent className="p-6">
<pre className="whitespace-pre-wrap font-mono text-sm">
{JSON.stringify(results, null, 2)}
</pre>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
</ProtectedRoute>
)
}
@@ -0,0 +1,161 @@
"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 { Alert, AlertDescription } from "@/components/ui/alert"
import { useAuth } from "@/lib/auth-context"
export default function ChangePasswordPage() {
const router = useRouter()
const { login, logout } = useAuth()
const [currentPassword, setCurrentPassword] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [user, setUser] = useState<{ id: number; username: string } | null>(null)
useEffect(() => {
const tempUser = localStorage.getItem("tempUser")
if (tempUser) {
setUser(JSON.parse(tempUser))
} else {
// If no user data is found, redirect to login
router.push("/login")
}
}, [router])
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setError(null)
setIsLoading(true)
if (newPassword !== confirmPassword) {
setError("New passwords don't match")
setIsLoading(false)
return
}
if (newPassword.length < 8) {
setError("New password must be at least 8 characters long")
setIsLoading(false)
return
}
try {
const response = await fetch(getApiUrl("change-password"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_id: user?.id,
currentPassword,
new_password: newPassword,
}),
})
const data = await response.json()
if (data.status === "success") {
// Clear the temporary user data from localStorage
localStorage.removeItem("tempUser")
// Log in the user with the updated data
login(data.user)
router.push("/devices")
} else {
setError(data.error?.replace(/[\[\]']+/g, "") || "Failed to change password")
}
} catch (err) {
setError("An error occurred while changing password")
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-background">
<div className="w-full max-w-sm mb-6 transition-all duration-500 hover:scale-105">
<img
src="/images/logos/Logo.png"
alt="IOT-Pre-Tester Logo"
style={{ width: "11rem" }}
className="block mx-auto drop-shadow-2xl"
/>
</div>
<Card className="w-full max-w-sm border-primary/20 shadow-xl">
<CardHeader className="text-center space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">Change Password Required</CardTitle>
<CardDescription>Please change your temporary password to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">Current Password</Label>
<Input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New Password</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Changing Password..." : "Change Password"}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
logout()
router.push("/login")
}}
disabled={isLoading}
>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,192 @@
"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 { useAuth } from "@/lib/auth-context"
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"
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: any) => ({
id: String(file.id),
name: file.filename,
uploadedAt: file.upload,
firmware: file.firmware_version,
ipAddress: file.ip,
}))
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("de-DE"),
files,
}
} catch (error) {
console.error("Failed to fetch device details:", error)
return null
}
}
const manufacturers = [
{ value: "manufacturerA", label: "Manufacturer A" },
{ value: "manufacturerB", label: "Manufacturer B" },
]
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) => {
console.log("Viewing scan results for file:", fileId)
router.push(`/scan-results?fileId=${fileId}`)
}
if (!device) {
return <div className="min-h-screen bg-background" />
}
return (
<ProtectedRoute>
{({ user, 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>
{/* Row with logo & logout button, spacing doubled via mb-16 */}
<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>
{/* Row with device name and details */}
{!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">
<img
src={getDeviceImage(device.type) || "/images/logos/Placeholder.png"}
alt={device.name}
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.firmware && <p>Firmware: {file.firmware}</p>}
{file.ipAddress && <p>IP Address: {file.ipAddress}</p>}
</div>
</div>
</div>
<Button
variant="default"
className="bg-emerald-600 hover:bg-emerald-700"
onClick={() => handleViewResults(file.id)}
>
View Results
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No files uploaded yet. Upload a file to start scanning.
</div>
)}
</ScrollArea>
</div>
<div className="flex justify-end mt-auto pt-4">
<Button onClick={() => router.push("/devices")}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Devices
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)}
</ProtectedRoute>
)
}
@@ -0,0 +1,379 @@
"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 { getDeviceImage } from "@/components/device-images"
import Image from "next/image"
import { getAllDevices, getManufacturers, getDeviceTypes, addDevice, deleteDevice } from "@/services/apiIOT"
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<any[]>([])
const [manufacturers, setManufacturers] = useState<any[]>([])
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",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: user?.id }),
})
} 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) => {
console.log("Navigating to device:", deviceId)
router.push(`/devices/${deviceId}`)
}
if (!user) {
return null // or a loading spinner
}
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">
<img
src="/images/logos/Logo.png"
alt="IOT-Pre-Tester Logo"
style={{ width: "8rem" }}
/>
</div>
<div className="flex justify-between items-center">
<Button variant="ghost" size="icon" onClick={handleLogout} className="ml-auto">
<LogOut className="h-4 w-4" />
<span className="sr-only">Logout</span>
</Button>
</div>
<div>
<CardTitle className="text-2xl">My Devices</CardTitle>
<CardDescription>Manage and monitor your IoT devices</CardDescription>
</div>
<div className="flex gap-2">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
Add New Device
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-xl font-bold text-foreground">Add New Device</DialogTitle>
<DialogDescription>Enter the details of your new IoT device.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-foreground">
Device Name
</Label>
<Input
id="name"
value={newDeviceName}
onChange={(e) => setNewDeviceName(e.target.value)}
placeholder="Enter device name"
className="text-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="manufacturer" className="text-foreground">
Manufacturer
</Label>
<Select value={newManufacturer} onValueChange={setNewManufacturer}>
<SelectTrigger className="text-foreground">
<SelectValue placeholder="Select manufacturer" />
</SelectTrigger>
<SelectContent>
{manufacturers.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="type" className="text-foreground">
Device Type
</Label>
<Select value={newDeviceType} onValueChange={setNewDeviceType}>
<SelectTrigger className="text-foreground">
<SelectValue placeholder="Select device type" />
</SelectTrigger>
<SelectContent>
{deviceTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="serialNumber" className="text-foreground">
Serial Number
</Label>
<Input
id="serialNumber"
value={newSerialNumber}
onChange={(e) => setNewSerialNumber(e.target.value)}
placeholder="Enter serial number"
className="text-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="comment" className="text-foreground">
Comment
</Label>
<Input
id="comment"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Enter optional comment"
className="text-foreground"
/>
</div>
</div>
<DialogFooter>
<Button variant="destructive" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleAddDevice}
disabled={!newDeviceName || !newDeviceType || !newManufacturer || !newSerialNumber}
>
Add Device
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{(user.role === "admin" || user.role === "superuser") && (
<>
<Button className="w-full sm:w-auto" onClick={() => router.push("/manage-users")}>
<Users className="mr-2 h-4 w-4" />
Manage Users
</Button>
<Button className="w-full sm:w-auto" onClick={() => router.push("/manage-device-types")}>
<Settings className="mr-2 h-4 w-4" />
Manage Device Types
</Button>
<Button className="w-full sm:w-auto" onClick={() => router.push("/manage-manufacturers")}>
<Settings className="mr-2 h-4 w-4" />
Manage Manufacturers
</Button>
<Button className="w-full sm:w-auto"
onClick={() => window.open("http://127.0.0.1:8000", "_blank")}
>
<Settings className="mr-2 h-4 w-4" />
Manage Database
</Button>
</>
)}
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[calc(100vh-19rem)]">
<div className="space-y-4">
{devices.map((device) => (
<Card key={device.id} className="relative overflow-hidden bg-card">
<div className="absolute inset-0 z-0">
<div className="relative w-3/5 h-full ml-auto">
{/*<Image*/}
{/* src={getDeviceImage(device.type) || "/placeholder.svg"}*/}
{/* alt={`${device.name} image`}*/}
{/* layout="fill"*/}
{/* className="object-cover opacity-75 mix-blend-multiply"*/}
{/* priority={false}*/}
{/*/>*/}
</div>
</div>
<CardHeader className="pb-2 relative z-10">
<div className="flex justify-between items-start">
<div className="space-y-1">
<CardTitle>{device.name}</CardTitle>
<CardDescription className="space-y-1">
<span className="block">
{deviceTypes.find((t) => t.value === device.type)?.label || device.type}
</span>
<span className="block">
Manufacturer:{" "}
{manufacturers.find((m) => m.value === device.manufacturer)?.label ||
device.manufacturer}
</span>
<span className="block">Serial Number: {device.serialNumber}</span>
{device.comment && <span className="block text-sm">Comment: {device.comment}</span>}
</CardDescription>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(device.id)}
className="absolute top-2 right-2"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete device</span>
</Button>
</div>
</CardHeader>
<CardContent className="pb-4 relative z-10">
<div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground">
{device.files?.length || 0} files
{device.lastScanned && ` • Last scan: ${device.lastScanned}`}
</div>
<Button size="sm" onClick={() => navigateToDevice(device.id)} className="ml-2">
View Device
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
{devices.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No devices added yet. Add your first device to get started.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-foreground">Delete Device</DialogTitle>
<DialogDescription>
Are you sure you want to delete this device? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ProtectedRoute>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer base {
:root {
--background: 210 40% 98%;
--foreground: 222.2 47.4% 11.2%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 214 32% 91%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 217 91% 95%;
--accent-foreground: 221 83% 45%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 88%;
--input: 214.3 31.8% 86%;
--ring: 221 83% 53%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3%;
--foreground: 0 0% 0%;
--card: 0 0% 5%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@@ -0,0 +1,30 @@
import './globals.css'
import { Inter } from 'next/font/google'
import { Providers } from '@/components/providers'
import { Toaster } from "@/components/ui/toaster"
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'IOT-PRE-TESTER',
description: 'File analysis tool for IOT devices',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.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">
V4.5 Demoversion
</div>
</body>
</html>
)
}
@@ -0,0 +1,115 @@
"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"
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",
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 if (data.resetPassword) {
// Store user data in localStorage for the change password page
localStorage.setItem('tempUser', JSON.stringify(data.user))
router.push("/change-password")
} else {
setError(data.message || "Invalid username or password")
}
} catch (err) {
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">
<img
src="/images/logos/Logo.png"
alt="IOT-Pre-Tester Logo"
style={{ width: "14rem" }}
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">V4.5 Demoversion</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: any) => ({
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: any) => ({
value: item.id,
label: item.name,
}))
setManufacturers(formattedManufacturers)
} catch (error) {
console.error("Error fetching manufacturers:", error)
}
}
useEffect(() => {
fetchManufacturers()
}, [])
const addManufacturer = async () => {
if (newManufacturerLabel.trim()) {
try {
await axios.post(getApiUrl("create_manufact"), { name: newManufacturerLabel.trim() })
setNewManufacturerLabel("")
fetchManufacturers()
} catch (error) {
console.error("Error adding manufacturer:", error)
}
}
}
const handleDeleteClick = (value: number) => {
setManufacturerToDelete(value)
setDeleteDialogOpen(true)
}
const confirmDelete = async () => {
if (manufacturerToDelete !== null) {
try {
await axios.delete(getApiUrl(`delete_manufacturer/${manufacturerToDelete}`))
fetchManufacturers()
setDeleteDialogOpen(false)
setManufacturerToDelete(null)
} catch (error) {
console.error("Error deleting manufacturer:", error)
}
}
}
return (
<ProtectedRoute>
<div className="min-h-screen p-4 bg-background">
<div className="max-w-4xl mx-auto space-y-8">
<Card>
<CardHeader>
<CardTitle className="text-3xl">Manage Manufacturers</CardTitle>
<CardDescription>
Add or remove manufacturers that users can select when adding new devices
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-4">
<div className="flex space-x-4">
<div className="flex-grow">
<Label htmlFor="manufacturerLabel">Manufacturer Name</Label>
<Input
id="manufacturerLabel"
value={newManufacturerLabel}
onChange={(e) => setNewManufacturerLabel(e.target.value)}
placeholder="Enter manufacturer name"
/>
</div>
<div className="flex items-end">
<Button onClick={addManufacturer}>
<Plus className="mr-2 h-4 w-4" />
Add Manufacturer
</Button>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold">Available Manufacturers</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Display Name</TableHead>
<TableHead>Value</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{manufacturers.map((manufacturer) => (
<TableRow key={manufacturer.value}>
<TableCell>{manufacturer.label}</TableCell>
<TableCell className="font-mono text-sm">{manufacturer.value}</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteClick(manufacturer.value)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-end mt-6">
<Button onClick={() => router.push("/devices")}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Devices
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-foreground">Delete Manufacturer</DialogTitle>
<DialogDescription>
Are you sure you want to delete this manufacturer? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ProtectedRoute>
)
}
@@ -0,0 +1,403 @@
"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"
import { Toaster } from "@/components/ui/toaster"
interface User {
id: number
username: string
role: string
password: string
}
function generatePassword() {
const length = 12
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
let password = ""
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * charset.length)
password += charset[randomIndex]
}
return password
}
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">
<Toaster />
<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">One-time 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>Password Hash</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>
<code className="text-xs bg-muted p-1 rounded break-all">{user.password}</code>
</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>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-foreground">Delete User</DialogTitle>
<DialogDescription>
Are you sure you want to delete this user? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reset Password Confirmation Dialog */}
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-foreground">Reset Password</DialogTitle>
<DialogDescription>
Are you sure you want to reset this user's password? They will need to change it on their next login.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setResetPasswordDialogOpen(false)}>Cancel</Button>
<Button variant="default" onClick={confirmResetPassword}>
Reset Password
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ProtectedRoute>
)
}
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/devices')
}
@@ -0,0 +1,524 @@
"use client"
import { getApiUrl } from "@/config/api"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { ProtectedRoute } from "@/components/protected-route"
import { Badge } from "@/components/ui/badge"
import { ArrowLeft, FileJson, Download, FileText, Wifi, Key, Mail, Globe, MapPin, Shield, AlertTriangle, X } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Input } from "@/components/ui/input"
interface BinwalkResult {
id: string
offset: string
description: string
file_path: string | null
created: string
}
interface StringsResult {
id: string
string_value: string
string_type: string
created: string
}
const STRING_TYPE_CONFIG: Record<string, { icon: any; color: string; priority: number }> = {
password: { icon: Key, color: "bg-red-500/10 text-red-500 border-red-500/20 hover:bg-red-500/20", priority: 1 },
ssid: { icon: Wifi, color: "bg-blue-500/10 text-blue-500 border-blue-500/20 hover:bg-blue-500/20", priority: 2 },
cloud_token: { icon: Shield, color: "bg-purple-500/10 text-purple-500 border-purple-500/20 hover:bg-purple-500/20", priority: 3 },
api_key: { icon: Shield, color: "bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/20", priority: 4 },
location: { icon: MapPin, color: "bg-green-500/10 text-green-500 border-green-500/20 hover:bg-green-500/20", priority: 5 },
credentials: { icon: Key, color: "bg-red-400/10 text-red-400 border-red-400/20 hover:bg-red-400/20", priority: 6 },
certificate: { icon: Shield, color: "bg-purple-400/10 text-purple-400 border-purple-400/20 hover:bg-purple-400/20", priority: 7 },
email: { icon: Mail, color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20 hover:bg-cyan-500/20", priority: 8 },
url: { icon: Globe, color: "bg-green-400/10 text-green-400 border-green-400/20 hover:bg-green-400/20", priority: 9 },
ip: { icon: Globe, color: "bg-blue-400/10 text-blue-400 border-blue-400/20 hover:bg-blue-400/20", priority: 10 },
mac: { icon: MapPin, color: "bg-indigo-500/10 text-indigo-500 border-indigo-500/20 hover:bg-indigo-500/20", priority: 11 },
device_id: { icon: Shield, color: "bg-teal-500/10 text-teal-500 border-teal-500/20 hover:bg-teal-500/20", priority: 12 },
serial: { icon: FileText, color: "bg-gray-500/10 text-gray-500 border-gray-500/20 hover:bg-gray-500/20", priority: 13 },
path: { icon: FileText, color: "bg-gray-400/10 text-gray-400 border-gray-400/20 hover:bg-gray-400/20", priority: 14 },
generic: { icon: FileText, color: "bg-gray-300/10 text-gray-300 border-gray-300/20 hover:bg-gray-300/20", priority: 15 },
}
function ScanResultsFirmwarePageContent() {
const [fileType, setFileType] = useState<"pcap" | "firmware" | null>(null)
const [binwalkResults, setBinwalkResults] = useState<BinwalkResult[]>([])
const [stringsResults, setStringsResults] = useState<StringsResult[]>([])
const [filteredStrings, setFilteredStrings] = useState<StringsResult[]>([])
const [selectedType, setSelectedType] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState<string>("")
const router = useRouter()
const searchParams = useSearchParams()
useEffect(() => {
const fetchData = async () => {
const fileId = searchParams.get("fileId")
if (!fileId) return
try {
// Try to load PCAP data
const urlsResponse = await fetch(getApiUrl(`get_url_raw_data_from_file/${fileId}`))
const urlsData = await urlsResponse.json()
if (Array.isArray(urlsData) && urlsData.length > 0) {
setFileType("pcap")
return
}
// If no PCAP data, load firmware data
const binwalkResponse = await fetch(getApiUrl(`get_binwalk_raw_data_from_file/${fileId}`))
const stringsResponse = await fetch(getApiUrl(`get_strings_raw_data_from_file/${fileId}`))
const binwalkData = await binwalkResponse.json()
const stringsData = await stringsResponse.json()
// Safely handle data: Check if arrays
const safeBinwalkData = Array.isArray(binwalkData) ? binwalkData : []
const safeStringsData = Array.isArray(stringsData) ? stringsData : []
if (safeStringsData.length > 0 || safeBinwalkData.length > 0) {
setFileType("firmware")
setBinwalkResults(safeBinwalkData)
setStringsResults(safeStringsData)
setFilteredStrings(safeStringsData)
}
} catch (error) {
console.error("Error fetching data:", error)
}
}
fetchData()
}, [searchParams])
// Filter strings based on selected type and search
useEffect(() => {
let filtered = stringsResults
// Filter by selected type
if (selectedType) {
filtered = filtered.filter(s => s.string_type === selectedType)
}
// Filter by search term
if (searchQuery) {
filtered = filtered.filter(s =>
s.string_value.toLowerCase().includes(searchQuery.toLowerCase())
)
}
setFilteredStrings(filtered)
}, [selectedType, searchQuery, stringsResults])
// String statistics
const stringStats = stringsResults.reduce((acc, str) => {
acc[str.string_type] = (acc[str.string_type] || 0) + 1
return acc
}, {} as Record<string, number>)
// Critical/important strings
const criticalTypes = ['password', 'ssid', 'cloud_token', 'api_key', 'location', 'credentials', 'certificate']
const criticalStrings = stringsResults.filter(s => criticalTypes.includes(s.string_type))
// Export functions
const handleExportAllStrings = () => {
const content = stringsResults
.map(s => `[${s.string_type}] ${s.string_value}`)
.join('\n')
downloadFile(content, 'all-strings.txt')
}
const handleExportCriticalStrings = () => {
const content = criticalStrings
.map(s => `[${s.string_type}] ${s.string_value}`)
.join('\n')
downloadFile(content, 'critical-strings.txt')
}
const handleExportBinwalk = () => {
const content = binwalkResults
.map(b => `${b.offset.padEnd(12)} ${b.description}${b.file_path ? ' -> ' + b.file_path : ''}`)
.join('\n')
downloadFile(content, 'binwalk-results.txt')
}
const handleExportJSON = () => {
const exportData = {
binwalk: binwalkResults,
strings: stringsResults,
statistics: stringStats
}
const dataStr = JSON.stringify(exportData, null, 2)
const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr)
const linkElement = document.createElement("a")
linkElement.setAttribute("href", dataUri)
linkElement.setAttribute("download", 'firmware-analysis.json')
linkElement.click()
}
const downloadFile = (content: string, filename: string) => {
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
// Group binwalk by type
const binwalkByType = binwalkResults.reduce((acc, result) => {
const type = result.description.split(' ')[0]
if (!acc[type]) acc[type] = []
acc[type].push(result)
return acc
}, {} as Record<string, BinwalkResult[]>)
// Type filter handler
const handleTypeClick = (type: string) => {
if (selectedType === type) {
setSelectedType(null) // Deselect when clicked again
} else {
setSelectedType(type)
setSearchQuery("") // Reset search when filtering by type
}
}
const clearFilters = () => {
setSelectedType(null)
setSearchQuery("")
}
if (!fileType) {
return (
<ProtectedRoute>
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
<Card className="w-96">
<CardHeader>
<CardTitle>Loading...</CardTitle>
<CardDescription>Fetching scan results</CardDescription>
</CardHeader>
</Card>
</div>
</ProtectedRoute>
)
}
if (fileType === "pcap") {
return (
<ProtectedRoute>
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
<Card className="w-96">
<CardHeader>
<CardTitle>PCAP Analysis</CardTitle>
<CardDescription>Use the original PCAP viewer</CardDescription>
</CardHeader>
</Card>
</div>
</ProtectedRoute>
)
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-background p-4 flex items-start">
<div className="flex gap-8 max-w-[1400px] mx-auto justify-center w-full h-[calc(100vh-32px)]">
<div className="flex-1">
<Card className="w-full h-full flex flex-col">
<CardHeader>
<div className="flex justify-between items-start mb-4">
<div className="w-64">
<img src="/images/logos/Logo.png" alt="IOT-Pre-Tester Logo" style={{ width: "8rem" }} />
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleExportAllStrings}>
<FileText className="mr-2 h-4 w-4" />
All Strings (.txt)
</Button>
<Button variant="outline" onClick={handleExportJSON}>
<FileJson className="mr-2 h-4 w-4" />
Export JSON
</Button>
</div>
</div>
<Button
onClick={() => {
const lastDeviceId = sessionStorage.getItem("lastDeviceId")
if (lastDeviceId) {
router.push(`/devices/${lastDeviceId}`)
} else {
router.push("/")
}
}}
className="w-fit mb-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Device
</Button>
<div className="flex items-center gap-3">
<CardTitle className="text-3xl">Firmware Analysis</CardTitle>
<Badge variant="secondary" className="text-sm">Firmware</Badge>
</div>
<CardDescription>
Binwalk and strings analysis results from your firmware file
</CardDescription>
</CardHeader>
<CardContent className="flex-grow overflow-hidden">
<Tabs defaultValue="overview" className="w-full h-full flex flex-col">
<TabsList className="grid w-full grid-cols-3 mb-4">
<TabsTrigger value="overview" className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
Overview
</TabsTrigger>
<TabsTrigger value="strings" className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
All Strings ({stringsResults.length})
</TabsTrigger>
<TabsTrigger value="binwalk" className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
Binwalk ({binwalkResults.length})
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
<div className="space-y-6">
{/* Statistics Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="text-sm text-muted-foreground mb-1">Total Strings</div>
<div className="text-3xl font-bold">{stringsResults.length}</div>
</Card>
<Card className="p-4">
<div className="text-sm text-muted-foreground mb-1">Critical Findings</div>
<div className="text-3xl font-bold text-red-500">{criticalStrings.length}</div>
</Card>
<Card className="p-4">
<div className="text-sm text-muted-foreground mb-1">Binwalk Signatures</div>
<div className="text-3xl font-bold">{binwalkResults.length}</div>
</Card>
<Card className="p-4">
<div className="text-sm text-muted-foreground mb-1">String Types</div>
<div className="text-3xl font-bold">{Object.keys(stringStats).length}</div>
</Card>
</div>
{/* String Type Distribution - MOVED UP */}
<Card>
<CardHeader>
<CardTitle className="text-xl">String Type Distribution</CardTitle>
<CardDescription>Click on a type to filter results</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{Object.entries(stringStats)
.sort((a, b) => {
const priorityA = STRING_TYPE_CONFIG[a[0]]?.priority || 999
const priorityB = STRING_TYPE_CONFIG[b[0]]?.priority || 999
return priorityA - priorityB
})
.map(([type, count]) => {
const config = STRING_TYPE_CONFIG[type]
const Icon = config?.icon || FileText
const isSelected = selectedType === type
return (
<Card
key={type}
className={`p-3 border cursor-pointer transition-all ${isSelected
? 'ring-2 ring-primary ' + (config?.color || '')
: config?.color || ''
}`}
onClick={() => handleTypeClick(type)}
>
<div className="flex items-center gap-2 mb-1">
<Icon className="h-4 w-4" />
<div className="text-sm font-semibold capitalize">{type}</div>
</div>
<div className="text-2xl font-bold">{count}</div>
<div className="text-xs text-muted-foreground">
{((count / stringsResults.length) * 100).toFixed(1)}%
</div>
</Card>
)
})}
</div>
</CardContent>
</Card>
{/* Critical Strings Section */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xl flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
{selectedType ? `Filtered: ${selectedType}` : 'Critical Findings'}
</CardTitle>
<CardDescription>
{selectedType
? `Showing ${filteredStrings.length} strings of type "${selectedType}"`
: 'Passwords, SSIDs, API Keys, and Credentials found in firmware'}
</CardDescription>
</div>
<div className="flex gap-2">
{selectedType && (
<Button variant="outline" size="sm" onClick={clearFilters}>
<X className="mr-2 h-4 w-4" />
Clear Filter
</Button>
)}
<Button variant="outline" size="sm" onClick={handleExportCriticalStrings}>
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
</CardHeader>
<CardContent>
<Input
placeholder="Search strings..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-4"
/>
<ScrollArea className="h-[400px]">
<div className="space-y-3 pr-4">
{(selectedType ? filteredStrings : criticalStrings.filter(s =>
!searchQuery || s.string_value.toLowerCase().includes(searchQuery.toLowerCase())
)).length > 0 ? (
(selectedType ? filteredStrings : criticalStrings.filter(s =>
!searchQuery || s.string_value.toLowerCase().includes(searchQuery.toLowerCase())
)).map((str) => {
const config = STRING_TYPE_CONFIG[str.string_type] || STRING_TYPE_CONFIG["generic"]
const Icon = config.icon
return (
<Card key={str.id} className={`p-4 border ${config.color}`}>
<div className="flex items-start gap-3">
<Icon className="h-5 w-5 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-xs uppercase">
{str.string_type}
</Badge>
</div>
<div className="font-mono text-sm break-all">
{str.string_value}
</div>
</div>
</div>
</Card>
)
})
) : (
<div className="text-center py-8 text-muted-foreground">
{searchQuery || selectedType ? "No matches found" : "No critical strings found"}
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="strings" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
<div className="space-y-2">
{stringsResults.map((str, idx) => {
const config = STRING_TYPE_CONFIG[str.string_type] || STRING_TYPE_CONFIG["generic"]
return (
<Card key={str.id} className="p-3">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground font-mono w-12">
#{idx + 1}
</span>
<Badge
variant="outline"
className={`text-xs uppercase flex items-center gap-1 ${config?.color || ''}`}
>
{config?.icon && <config.icon className="h-3 w-3" />}
{str.string_type}
</Badge>
<div className="flex-1 font-mono text-sm break-all">
{str.string_value}
</div>
</div>
</Card>
)
})}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="binwalk" className="flex-1 overflow-hidden">
<div className="flex justify-end mb-4">
<Button variant="outline" size="sm" onClick={handleExportBinwalk}>
<Download className="mr-2 h-4 w-4" />
Export Binwalk
</Button>
</div>
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
{Object.entries(binwalkByType).map(([type, results]) => (
<Card key={type}>
<CardHeader>
<CardTitle className="text-lg">{type}</CardTitle>
<CardDescription>{results.length} signature(s) found</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{results.map((result) => (
<Card key={result.id} className="p-4 bg-muted/50">
<div className="flex items-start gap-4">
<Badge variant="secondary" className="font-mono text-xs">
{result.offset}
</Badge>
<div className="flex-1">
<div className="text-sm font-medium">{result.description}</div>
{result.file_path && (
<div className="text-xs text-muted-foreground mt-1 font-mono">
{result.file_path}
</div>
)}
</div>
</div>
</Card>
))}
</div>
</CardContent>
</Card>
))}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
</div>
</ProtectedRoute>
)
}
export default function ScanResultsFirmwarePage() {
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>
}>
<ScanResultsFirmwarePageContent />
</Suspense>
)
}
@@ -0,0 +1,852 @@
"use client"
import { getApiUrl } from "@/config/api"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { ProtectedRoute } from "@/components/protected-route"
import { Badge } from "@/components/ui/badge"
import { ArrowLeft, FileJson, Download, Loader2, CheckCircle2, ChevronDown, XCircle } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"
interface UrlAnalysis {
query: string
target: string
tlsVersion: string
cipherSuite: string
//certificate: string
openPorts: string[]
}
interface HashAnalysis {
// original hash value as provided
original: string
// detected hash type/algorithm if available
hashType: string | null
// cracked cleartext value if available
cracked: string | null
// time needed for cracking (raw string as provided by backend)
crackTime: string | null
}
interface UrlResult {
id: string
url: string
created: string
}
interface HashResult {
id: string
hash_value: string
created: string
}
interface ScanResults {
urls: UrlResult[]
hashes: HashResult[]
}
type AnalysisStatus = "pending" | "running" | "completed" | "failed"
interface ItemAnalysis {
status: AnalysisStatus
result?: UrlAnalysis | HashAnalysis
}
type AnalysisResults = Record<string, ItemAnalysis>
const COMMANDS = [
{
value: "curl",
label: "Curl",
description: "Retrieve and send HTTP requests to analyze headers, content, and server information.",
applicableGroups: ["urls"],
},
{
value: "wget",
label: "Wget",
description: "Download files from a server or check resource availability.",
applicableGroups: ["urls"],
},
{
value: "host",
label: "Host",
description: "Resolve hostnames to IP addresses and display DNS information.",
applicableGroups: ["urls"],
},
{
value: "sslscan",
label: "SSLScan",
description: "Scan SSL/TLS configurations of a server, including supported protocols and cipher suites.",
applicableGroups: ["urls"],
},
{
value: "hashcat",
label: "Hashcat",
description: "Advanced password recovery and hash cracking tool supporting multiple algorithms.",
applicableGroups: ["hashes"],
},
]
function ScanResultsUrlHashPageContent() {
// Safely format any value for display to avoid React render errors on objects/functions
const formatDisplay = (value: any): string => {
if (value === null || value === undefined) return "N/A"
if (Array.isArray(value)) return value.map((v) => formatDisplay(v)).join(", ")
const t = typeof value
if (t === "object") {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
if (t === "boolean" || t === "number" || t === "bigint") return String(value)
return value as string
}
// Normalize hash type coming from backend which can be string, number, object, or array
const normalizeHashType = (data: any): string | null => {
// helper to extract a readable string from mixed values
const extract = (v: any): string | null => {
if (v === null || v === undefined) return null
const t = typeof v
if (t === "string" || t === "number" || t === "bigint" || t === "boolean") return String(v)
if (Array.isArray(v)) {
const parts = v.map((x) => extract(x)).filter(Boolean) as string[]
return parts.length ? parts.join(", ") : null
}
if (t === "object") {
// try common key names that may hold the human-readable hash type
const keys = [
"hash_type",
"algorithm",
"name",
"type",
"value",
"label",
"display_name",
]
for (const k of keys) {
if (k in v) {
const res = extract(v[k])
if (res) return res
}
}
return null
}
return null
}
const candidates = [
data?.hash_type,
data?.algorithm,
data?.type,
data?.hashcat_mode,
data?.mode,
]
for (const c of candidates) {
const res = extract(c)
if (res) return res
}
return null
}
const [urlResults, setUrlResults] = useState<UrlResult[]>([])
const [hashResults, setHashResults] = useState<HashResult[]>([])
const [selectedUrls, setSelectedUrls] = useState<string[]>([])
const [selectedHashes, setSelectedHashes] = useState<string[]>([])
const [urlAnalysisResults, setUrlAnalysisResults] = useState<AnalysisResults>({})
const [hashAnalysisResults, setHashAnalysisResults] = useState<AnalysisResults>({})
const [isAnalyzing, setIsAnalyzing] = useState(false)
const router = useRouter()
const searchParams = useSearchParams()
// Try to extract a human-readable crack time from raw hashcat output text
const parseCrackTimeFromHashcatOutput = (text: any): string | null => {
if (!text) return null
const str = typeof text === "string" ? text : (() => {
try {
return JSON.stringify(text)
} catch {
return String(text)
}
})()
// 1) Direct parenthetical duration on Time.Started line, e.g. "Time.Started.....: ... (1 sec)"
const startedParenMatch = str.match(/Time\.Started[^\n]*\(([^)]+)\)/i)
if (startedParenMatch && startedParenMatch[1]) {
return startedParenMatch[1].trim()
}
// 2) Parse Started/Stopped timestamps and compute delta
const startedMatch = str.match(/Started:\s*([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})/)
const stoppedMatch = str.match(/Stopped:\s*([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})/)
if (startedMatch && stoppedMatch) {
const start = new Date(startedMatch[1])
const stop = new Date(stoppedMatch[1])
if (!isNaN(start.getTime()) && !isNaN(stop.getTime())) {
const diffMs = Math.max(0, stop.getTime() - start.getTime())
const seconds = Math.round(diffMs / 1000)
if (seconds < 60) return `${seconds} sec`
const minutes = Math.floor(seconds / 60)
const remSec = seconds % 60
if (minutes < 60) return remSec ? `${minutes}m ${remSec}s` : `${minutes}m`
const hours = Math.floor(minutes / 60)
const remMin = minutes % 60
return remMin ? `${hours}h ${remMin}m` : `${hours}h`
}
}
// 3) Fallback: look for simple patterns like "(0 secs)" anywhere
const simpleParen = str.match(/\((\d+\s*(?:sec|secs|s|ms|minutes?|mins?))\)/i)
if (simpleParen && simpleParen[1]) return simpleParen[1].trim()
return null
}
useEffect(() => {
const fetchData = async () => {
const fileId = searchParams.get("fileId")
if (!fileId) {
console.error("No fileId provided")
return
}
try {
const urlsResponse = await fetch(getApiUrl(`get_url_raw_data_from_file/${fileId}`))
const hashesResponse = await fetch(getApiUrl(`get_hash_raw_data_from_file/${fileId}`))
const urlsData = await urlsResponse.json()
const hashesData = await hashesResponse.json()
// Safely set data: Ensure it is an array before setting state
setUrlResults(Array.isArray(urlsData) ? urlsData : [])
setHashResults(Array.isArray(hashesData) ? hashesData : [])
} catch (error) {
console.error("Error fetching data:", error)
setUrlResults([])
setHashResults([])
}
}
fetchData()
}, [searchParams])
const toggleSelectAllUrls = () => {
setSelectedUrls(selectedUrls.length === urlResults.length ? [] : urlResults.map((item) => item.id))
}
const toggleSelectAllHashes = () => {
setSelectedHashes(selectedHashes.length === hashResults.length ? [] : hashResults.map((item) => item.id))
}
const toggleUrlItem = (itemId: string) => {
setSelectedUrls((prev) => (prev.includes(itemId) ? prev.filter((i) => i !== itemId) : [...prev, itemId]))
}
const toggleHashItem = (itemId: string) => {
setSelectedHashes((prev) => (prev.includes(itemId) ? prev.filter((i) => i !== itemId) : [...prev, itemId]))
}
const totalSelectedItems = selectedUrls.length + selectedHashes.length
const executeAnalysis = async () => {
console.log("Selected IDs for analysis:", {
urls: selectedUrls,
hashes: selectedHashes,
})
setIsAnalyzing(true)
if (selectedUrls.length > 0) {
await analyzeUrls()
}
if (selectedHashes.length > 0) {
await analyzeHashes()
}
setIsAnalyzing(false)
}
const analyzeUrls = async () => {
const newUrlAnalysisResults: AnalysisResults = {}
for (const urlId of selectedUrls) {
newUrlAnalysisResults[urlId] = { status: "running" }
setUrlAnalysisResults((prev) => ({ ...prev, [urlId]: { status: "running" } }))
try {
const endpoint = getApiUrl(`get_all_urls_analyzed_by_raw_url/${urlId}`)
const response = await fetch(endpoint)
const responseData = await response.json()
if (responseData.status === "success" && responseData.data && responseData.data.length > 0) {
const analysisData = responseData.data[0]
newUrlAnalysisResults[urlId] = {
status: "completed",
result: {
query: "GET / HTTP/1.1",
target: analysisData.url,
tlsVersion: analysisData.tls_versions[0]?.tls_version || "N/A",
cipherSuite: analysisData.cipher_suites[0]?.cipher_suite || "N/A",
//certificate: "N/A",
openPorts: Array.isArray(analysisData.ports)
? analysisData.ports.map((p: any) => p?.port?.toString() || "")
: [],
},
}
} else {
throw new Error("No analysis data found")
}
} catch (error) {
console.error(`Analysis failed for URL ${urlId}:`, error)
newUrlAnalysisResults[urlId] = { status: "failed" }
}
setUrlAnalysisResults((prev) => ({ ...prev, [urlId]: newUrlAnalysisResults[urlId] }))
}
}
const analyzeHashes = async () => {
const newHashAnalysisResults: AnalysisResults = {}
for (const hashId of selectedHashes) {
newHashAnalysisResults[hashId] = { status: "running" }
setHashAnalysisResults((prev) => ({ ...prev, [hashId]: { status: "running" } }))
try {
const endpoint = getApiUrl(`get_all_hashes_analyzed_by_raw_hash/${hashId}`)
const response = await fetch(endpoint)
const responseData = await response.json()
if (responseData.status === "success" && responseData.data && responseData.data.length > 0) {
const analysisData = responseData.data[0]
// retrieve original hash from current list by id
const originalHash = hashResults.find((h) => h.id === hashId)?.hash_value || ""
// normalize hash type; backend may return plain string or nested object
const hashType: string | null = normalizeHashType(analysisData)
// cracked value can be under different keys; prefer cracked_value when cracked=true
const cracked: string | null = analysisData.cracked
? analysisData.cracked_value || analysisData.cleartext || analysisData.password || analysisData.plaintext || null
: null
// crack time might be provided under different keys
let crackTime: string | null =
analysisData.crack_time ||
analysisData.time_taken ||
analysisData.duration ||
(analysisData.duration_seconds != null ? `${analysisData.duration_seconds}s` : null) ||
null
// If backend didn't provide explicit duration, try to parse from raw hashcat output
if (!crackTime) {
const possibleOutput =
analysisData.hashcat_output ||
analysisData.output ||
analysisData.stdout ||
analysisData.logs ||
analysisData.full_output ||
analysisData.debug_output ||
null
const parsed = parseCrackTimeFromHashcatOutput(possibleOutput)
if (parsed) crackTime = parsed
}
newHashAnalysisResults[hashId] = {
status: "completed",
result: {
original: originalHash,
hashType,
cracked,
crackTime,
},
}
} else {
throw new Error("No analysis data found")
}
} catch (error) {
console.error(`Analysis failed for hash ${hashId}:`, error)
newHashAnalysisResults[hashId] = { status: "failed" }
}
setHashAnalysisResults((prev) => ({ ...prev, [hashId]: newHashAnalysisResults[hashId] }))
}
}
const isAnalysisComplete = () => {
const urlAnalysisItems = selectedUrls.map((id) => urlAnalysisResults[id])
const hashAnalysisItems = selectedHashes.map((id) => hashAnalysisResults[id])
const analysisItems = [...urlAnalysisItems, ...hashAnalysisItems]
return (
analysisItems.length > 0 && analysisItems.every((item) => item && ["completed", "failed"].includes(item.status))
)
}
const handleExportJSON = () => {
const exportData = {
urls: urlAnalysisResults,
hashes: hashAnalysisResults,
}
const dataStr = JSON.stringify(exportData, null, 2)
const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr)
const exportFileDefaultName = "analysis-results.json"
const linkElement = document.createElement("a")
linkElement.setAttribute("href", dataUri)
linkElement.setAttribute("download", exportFileDefaultName)
linkElement.click()
}
const handleExportPDF = () => {
// Mock PDF export
alert("PDF export initiated")
}
const getStatusIcon = (status: AnalysisStatus) => {
switch (status) {
case "running":
return <Loader2 className="h-4 w-4 animate-spin" />
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />
default:
return null
}
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-background p-4 flex items-start">
<div className="flex gap-8 max-w-[1400px] mx-auto justify-center w-full h-[calc(100vh-32px)]">
<div className="flex-1">
<Card className="w-full h-full flex flex-col">
<CardHeader>
<div className="flex justify-between items-start mb-4">
<div className="w-64">
<img
src="/images/logos/Logo.png"
alt="IOT-Pre-Tester Logo"
style={{ width: "8rem" }}
/>
</div>
<div className="flex gap-2">
{/*
<Button
variant={isAnalysisComplete() ? "default" : "outline"}
onClick={handleExportPDF}
disabled={!isAnalysisComplete()}
>
<Download className="mr-2 h-4 w-4" />
Export PDF
</Button>
*/}
<Button
variant={isAnalysisComplete() ? "default" : "outline"}
onClick={handleExportJSON}
disabled={!isAnalysisComplete()}
>
<FileJson className="mr-2 h-4 w-4" />
Export JSON
</Button>
</div>
</div>
<Button
onClick={() => {
const lastDeviceId = sessionStorage.getItem("lastDeviceId")
if (lastDeviceId) {
router.push(`/devices/${lastDeviceId}`)
} else {
router.push("/")
}
}}
className="w-fit mb-8"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Device
</Button>
<CardTitle className="text-3xl">Scan Results</CardTitle>
<CardDescription>Here are the findings from your file scan. Select items to analyze.</CardDescription>
</CardHeader>
<CardContent className="flex-grow overflow-hidden">
<ScrollArea className="h-full">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 pr-4">
<div className="lg:col-span-2 space-y-6">
{urlResults.length > 0 && (
<Card key="urls">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle className="text-xl capitalize">URLs</CardTitle>
<CardDescription>{urlResults.length} items found</CardDescription>
</div>
{selectedUrls.length > 0 && (
<Badge variant="secondary" className="text-sm">
{selectedUrls.length} selected
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="urls">
<AccordionTrigger>View Results</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="select-all-urls"
checked={selectedUrls.length === urlResults.length}
onCheckedChange={toggleSelectAllUrls}
/>
<label
htmlFor="select-all-urls"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Select All
</label>
</div>
<div className="space-y-2">
{urlResults.map((item, index) => (
<div key={item.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id={`urls-${index}`}
checked={selectedUrls.includes(item.id)}
onCheckedChange={() => toggleUrlItem(item.id)}
/>
<label
htmlFor={`urls-${index}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{item.url}
</label>
</div>
<div className="flex items-center gap-2">
{urlAnalysisResults[item.id] && (
<>
{getStatusIcon(urlAnalysisResults[item.id].status)}
{urlAnalysisResults[item.id].status === "completed" && (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
View Results
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="text-foreground text-2xl">
Analysis Results
</DialogTitle>
<DialogDescription className="text-lg">
Detailed analysis results for {item.url}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 mt-4">
<div className="grid gap-4">
{Object.entries(
urlAnalysisResults[item.id].result as UrlAnalysis,
).map(([key, value]) => (
<Card
key={key}
className="bg-background/5 p-4 rounded-lg"
>
<div className="font-semibold capitalize text-foreground text-lg mb-2">
{key.replace(/([A-Z])/g, " $1").trim()}
</div>
<div className="text-foreground/90">
{formatDisplay(value)}
</div>
</Card>
))}
</div>
</div>
<div className="flex justify-end mt-6">
<DialogTrigger asChild>
<Button>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</DialogTrigger>
</div>
</DialogContent>
</Dialog>
)}
</>
)}
</div>
</div>
</div>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
)}
{hashResults.length > 0 && (
<Card key="hashes">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle className="text-xl capitalize">Hashes</CardTitle>
<CardDescription>{hashResults.length} items found</CardDescription>
</div>
{selectedHashes.length > 0 && (
<Badge variant="secondary" className="text-sm">
{selectedHashes.length} selected
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="hashes">
<AccordionTrigger>View Results</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="select-all-hashes"
checked={selectedHashes.length === hashResults.length}
onCheckedChange={toggleSelectAllHashes}
/>
<label
htmlFor="select-all-hashes"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Select All
</label>
</div>
<div className="space-y-2">
{hashResults.map((item, index) => (
<div key={item.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id={`hashes-${index}`}
checked={selectedHashes.includes(item.id)}
onCheckedChange={() => toggleHashItem(item.id)}
/>
<label
htmlFor={`hashes-${index}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{item.hash_value}
</label>
</div>
<div className="flex items-center gap-2">
{hashAnalysisResults[item.id] && (
<>
{getStatusIcon(hashAnalysisResults[item.id].status)}
{hashAnalysisResults[item.id].status === "completed" && (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
View Results
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="text-foreground text-2xl">
Analysis Results
</DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4">
<div className="grid gap-4">
<Card className="bg-background/5 p-4 rounded-lg">
<div className="font-semibold text-foreground text-lg mb-2">
Original hash value
</div>
<div className="text-foreground/90 break-words">
{(() => {
const v = (hashAnalysisResults[item.id].result as HashAnalysis)
.original
return v ? formatDisplay(v) : "Unbekannt"
})()}
</div>
</Card>
<Card className="bg-background/5 p-4 rounded-lg">
<div className="font-semibold text-foreground text-lg mb-2">
hash type
</div>
<div className="text-foreground/90">
{(() => {
const v = (hashAnalysisResults[item.id].result as HashAnalysis)
.hashType
return v ? formatDisplay(v) : "Unbekannt"
})()}
</div>
</Card>
<Card className="bg-background/5 p-4 rounded-lg">
<div className="font-semibold text-foreground text-lg mb-2">
cracked value
</div>
<div className="text-foreground/90">
{(() => {
const v = (hashAnalysisResults[item.id].result as HashAnalysis)
.cracked
return v ? formatDisplay(v) : "Keiner"
})()}
</div>
</Card>
<Card className="bg-background/5 p-4 rounded-lg">
<div className="font-semibold text-foreground text-lg mb-2">
crack time
</div>
<div className="text-foreground/90">
{(() => {
const v = (hashAnalysisResults[item.id].result as HashAnalysis)
.crackTime
return v ? formatDisplay(v) : "Unbekannt"
})()}
</div>
</Card>
</div>
</div>
<div className="flex justify-end mt-6">
<DialogTrigger asChild>
<Button>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</DialogTrigger>
</div>
</DialogContent>
</Dialog>
)}
</>
)}
</div>
</div>
</div>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
)}
</div>
<div className="lg:col-span-1">
{(selectedUrls.length > 0 || selectedHashes.length > 0) && (
<Card className="sticky top-4">
<CardHeader>
<CardTitle className="text-xl">Executed Commands</CardTitle>
<CardDescription>Commands that will be executed on your selection</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{selectedUrls.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold capitalize">URLs</h4>
<Badge variant="secondary" className="text-xs">
{selectedUrls.length} selected
</Badge>
</div>
<div className="space-y-3 pl-4">
{COMMANDS.filter((command) => command.applicableGroups.includes("urls")).map(
(command) => (
<div key={command.value} className="space-y-1">
<div className="font-medium text-sm">{command.label}</div>
<p className="text-xs text-muted-foreground">{command.description}</p>
</div>
),
)}
</div>
</div>
)}
{(selectedUrls.length > 0 || selectedHashes.length > 0) && (
<div className="space-y-3">
{selectedHashes.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold capitalize">Hashes</h4>
<Badge variant="secondary" className="text-xs">
{selectedHashes.length} selected
</Badge>
</div>
<div className="space-y-3 pl-4">
{COMMANDS.filter((command) => command.applicableGroups.includes("hashes")).map(
(command) => (
<div key={command.value} className="space-y-1">
<div className="font-medium text-sm">{command.label}</div>
<p className="text-xs text-muted-foreground">{command.description}</p>
</div>
),
)}
</div>
</div>
)}
<div className="flex justify-end pt-6">
<Button
onClick={executeAnalysis}
variant="default"
size="lg"
className="text-lg px-8 py-6 bg-emerald-600 hover:bg-emerald-700"
disabled={isAnalyzing}
>
{isAnalyzing ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Analyzing...
</>
) : (
"Start Analysis"
)}
</Button>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</ProtectedRoute>
)
}
export default function ScanResultsUrlHashPage() {
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>
}>
<ScanResultsUrlHashPageContent />
</Suspense>
)
}
@@ -0,0 +1,97 @@
"use client"
import { getApiUrl } from "@/config/api"
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"
const ROUTE_V2_RESULTS = "/scan-results-urlhash"
const ROUTE_V3_RESULTS = "/scan-results-firmware"
function SmartResultsRedirectContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [status, setStatus] = useState<"idle" | "checking" | "redirected" | "error">("idle")
useEffect(() => {
const run = async () => {
const fileId = searchParams.get("fileId")
if (!fileId) return
setStatus("checking")
try {
// 1️⃣ PCAP/URL hash heuristic
const urlsRes = await fetch(getApiUrl(`get_url_raw_data_from_file/${fileId}`))
const urlsData = await urlsRes.json()
if (Array.isArray(urlsData) && urlsData.length > 0) {
router.replace(`${ROUTE_V2_RESULTS}?fileId=${encodeURIComponent(fileId)}`)
setStatus("redirected")
return
}
// Firmware heuristic
const [binwalkRes, stringsRes] = await Promise.all([
fetch(getApiUrl(`get_binwalk_raw_data_from_file/${fileId}`)),
fetch(getApiUrl(`get_strings_raw_data_from_file/${fileId}`)),
])
const [binwalkData, stringsData] = await Promise.all([binwalkRes.json(), stringsRes.json()])
if ((Array.isArray(binwalkData) && binwalkData.length > 0) || (Array.isArray(stringsData) && stringsData.length > 0)) {
router.replace(`${ROUTE_V3_RESULTS}?fileId=${encodeURIComponent(fileId)}`)
setStatus("redirected")
return
}
// Fallback (when no clear type was detected)
router.replace(`${ROUTE_V2_RESULTS}?fileId=${encodeURIComponent(fileId)}`)
setStatus("redirected")
} catch (error) {
console.error("Redirect decision failed:", error)
setStatus("error")
}
}
run()
}, [router, searchParams])
return (
<ProtectedRoute>
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
<Card className="w-96">
<CardHeader>
<CardTitle>
{status === "error" ? "Error" : "Loading..."}
</CardTitle>
<CardDescription>
{status === "error"
? "Couldn't determine result page. Please try again."
: "Determining the correct result page…"}
</CardDescription>
</CardHeader>
</Card>
</div>
</ProtectedRoute>
)
}
export default function SmartResultsRedirectPage() {
return (
<Suspense fallback={
<ProtectedRoute>
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
<Card className="w-96">
<CardHeader>
<CardTitle>Loading...</CardTitle>
<CardDescription>Please wait...</CardDescription>
</CardHeader>
</Card>
</div>
</ProtectedRoute>
}>
<SmartResultsRedirectContent />
</Suspense>
)
}
@@ -0,0 +1,23 @@
export const getDeviceImage = (deviceType: string) => {
switch (deviceType.toLowerCase()) {
case 'camera':
return '/images/devices/camera.png';
case 'smart-plug':
return '/images/devices/smart_plug.png';
case 'smart-bulb':
return '/images/devices/smart_bulb.png';
case 'smart-switch':
return '/images/devices/smart_switch.png';
case 'robot-vacuum':
case 'robot vacuum cleaner':
return '/images/devices/robot_vacuum_cleaner.png';
case 'smart-lock':
return '/images/devices/smart_lock.png';
case 'smart-controller':
return '/images/devices/smart_controller.png';
case 'sensor':
return '/images/devices/sensor.png';
default:
return '/images/logos/Placeholder.png';
}
};
@@ -0,0 +1,318 @@
"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, LogOut, ArrowLeft } from "lucide-react"
import { useDropzone } from "react-dropzone"
import type { FileRejection } from "react-dropzone"
import { uploadFile } from "@/services/upload-file"
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"
import { useAuth } from "@/lib/auth-context"
interface FileUploadProps {
onBack?: () => void
deviceId?: string
}
interface FileDetails {
customName: string
firmware: string
ipAddress: string
}
export function FileUpload({ onBack, deviceId }: FileUploadProps) {
const router = useRouter()
const { user, logout } = useAuth()
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [isScanning, setIsScanning] = useState(false)
const [fileDetails, setFileDetails] = useState<FileDetails>({
customName: "",
firmware: "",
ipAddress: "",
})
const onDrop = React.useCallback((acceptedFiles: File[], fileRejections: FileRejection[]) => {
if (fileRejections.length > 0) {
setError("Invalid file type. Please upload a .pcap, .pcapng, or .bin file only.")
return
}
if (acceptedFiles.length > 0) {
const selectedFile = acceptedFiles[0]
if (selectedFile.size > 1024 * 1024 * 1024) {
// 1GB in bytes
setError("File size exceeds 1GB limit.")
return
}
setFile(selectedFile)
setFileDetails((prev) => ({
...prev,
customName: selectedFile.name,
}))
setError(null)
setSuccess(false)
handleUpload(selectedFile)
}
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"application/vnd.tcpdump.pcap": [".pcap"],
"application/x-pcapng": [".pcapng"],
"application/octet-stream": [".bin", ".img", ".fw"],
},
maxFiles: 1,
multiple: false,
})
const simulateUpload = () => {
setUploadProgress(0)
const interval = setInterval(() => {
setUploadProgress((oldProgress) => {
if (oldProgress === 100) {
clearInterval(interval)
setSuccess(true)
return 100
}
return Math.min(oldProgress + 10, 100)
})
}, 200)
}
const handleUpload = async (selectedFile: File) => {
setUploadProgress(0)
const formData = new FormData()
formData.append("file", selectedFile)
try {
simulateUpload() // Start simulated progress
const response = await uploadFile(formData)
if (response.success) {
setSuccess(true)
setUploadProgress(100)
} else {
setError(response.error || "Failed to upload file")
}
} catch (error) {
console.error("Upload error:", error)
setError(`Failed to upload file: ${error instanceof Error ? error.message : String(error)}`)
}
}
const removeFile = () => {
setFile(null)
setUploadProgress(0)
setError(null)
setSuccess(false)
setFileDetails({
customName: "",
firmware: "",
ipAddress: "",
})
}
const handleScan = async () => {
if (!file || !fileDetails.customName) return
try {
setIsScanning(true)
setError(null)
// Determine the correct endpoint based on file extension
const fileExtension = file.name.toLowerCase().split('.').pop()
const isFirmware = fileExtension === 'bin' || fileExtension === 'img' || fileExtension === 'fw'
const endpoint = isFirmware
? getApiUrl("upload_firmware")
: getApiUrl("createfile")
const formData = new FormData()
formData.append("device_id", deviceId || "")
formData.append("firmware_version", fileDetails.firmware)
formData.append("filename", fileDetails.customName)
formData.append("ip_address", fileDetails.ipAddress)
formData.append("status", "scanned")
formData.append("file_path", `/filesAttachment/${file?.name}`)
const response = await axios.post(endpoint, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
console.log('API Response:', response.data.data)
const fileId = response.data.data.id
router.push(`/scan-results?fileId=${fileId}`)
} catch (err) {
console.error('Scan error:', err)
setError(`Failed to scan file: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsScanning(false)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFileDetails((prev) => ({
...prev,
[name]: value,
}))
}
// Determine the description based on the uploaded file
const getFileTypeDescription = () => {
if (!file) return "Upload a PCAP/PCAPNG file to scan for vulnerabilities or a .bin firmware file for firmware analysis"
const fileExtension = file.name.toLowerCase().split('.').pop()
const isFirmware = fileExtension === 'bin' || fileExtension === 'img' || fileExtension === 'fw'
return isFirmware
? "Firmware file detected - will analyze with binwalk and strings"
: "PCAP file detected - will analyze network traffic"
}
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">{getFileTypeDescription()}</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" : ""}
${success ? "border-green-500 bg-green-500/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, .pcapng (network traffic) or .bin, .img, .fw (firmware)
</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">
{file.name.toLowerCase().endsWith('.bin') ||
file.name.toLowerCase().endsWith('.img') ||
file.name.toLowerCase().endsWith('.fw')
? 'Firmware File'
: 'Network Traffic File'}
</span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={removeFile}>
<XIcon className="w-4 h-4" />
</Button>
</div>
<Progress value={uploadProgress} className="mt-4" />
</div>
{success && (
<div className="space-y-4">
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="customName">File Name</Label>
<Input
id="customName"
name="customName"
value={fileDetails.customName}
onChange={handleInputChange}
placeholder="Enter file name"
/>
</div>
<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.customName}
>
{isScanning ? (
<>
<ScanIcon className="mr-2 h-5 w-5 animate-spin" />
{file.name.toLowerCase().endsWith('.bin') ? 'Analyzing Firmware...' : 'Scanning...'}
</>
) : (
<>
<ScanIcon className="mr-2 h-5 w-5" />
{file.name.toLowerCase().endsWith('.bin') ? 'Analyze Firmware' : 'Scan File'}
</>
)}
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,29 @@
"use client"
import { useEffect, ReactNode } from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth-context"
interface ProtectedRouteProps {
children: ReactNode | ((props: { user: any; logout: () => void }) => ReactNode);
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user, logout } = useAuth()
const router = useRouter()
useEffect(() => {
if (!user) {
router.push("/login")
}
}, [user, router])
if (!user) {
return null
}
return typeof children === "function"
? children({ user, logout })
: children
}
@@ -0,0 +1,8 @@
'use client'
import { AuthProvider } from '@/lib/auth-context'
export function Providers({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>
}
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,37 @@
"use client"
export function AnimatedBackground() {
return (
<>
{/* Floating particles */}
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none">
{[...Array(20)].map((_, i) => (
<div
key={i}
className="absolute h-2 w-2 rounded-full bg-primary/30"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animation: `float ${5 + Math.random() * 10}s ease-in-out infinite`,
animationDelay: `${Math.random() * 5}s`,
}}
/>
))}
</div>
{/* CSS Animations */}
<style jsx>{`
@keyframes float {
0%, 100% {
transform: translateY(0) translateX(0);
opacity: 0.3;
}
50% {
transform: translateY(-20px) translateX(10px);
opacity: 0.6;
}
}
`}</style>
</>
)
}
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-primary/40",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
@@ -0,0 +1,32 @@
'use client'
import React, { useState, useEffect } from 'react'
interface NotificationProps {
message: string
type?: 'success' | 'error' | 'info'
duration?: number
}
export function Notification({ message, type = 'info', duration = 3000 }: NotificationProps) {
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(false), duration)
return () => clearTimeout(timer)
}, [duration])
if (!isVisible) return null
const bgColor = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-blue-500'
}[type]
return (
<div className={`fixed top-4 right-4 p-4 rounded-md text-white ${bgColor} shadow-lg transition-opacity`}>
{message}
</div>
)
}
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 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 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,126 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
export type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
export type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
@@ -0,0 +1,169 @@
"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
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
export function useToast() {
const [toasts, setToasts] = useState<ToasterToast[]>([])
return {
toasts,
toast: (props: Omit<ToasterToast, "id">) => {
const id = genId()
setToasts((toasts) => [
...toasts,
{
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) {
setToasts((toasts) => toasts.filter((t) => t.id !== id))
}
},
},
])
},
dismiss: (id: string) => {
setToasts((toasts) => toasts.filter((t) => t.id !== id))
}
}
}
@@ -0,0 +1,21 @@
/**
* API Configuration
* Centralized API URL configuration for environment-based deployment
*/
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
/**
* Helper function to build API endpoint URLs
* @param endpoint - The endpoint path (e.g., 'get_all_devices')
* @returns Full API URL
*/
export const getApiUrl = (endpoint: string): string => {
// Remove leading slash if present
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
// Ensure base URL doesn't end with slash
const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL;
return `${baseUrl}/${cleanEndpoint}`;
};
@@ -0,0 +1,12 @@
import axios from "axios"
export const BASE_URL = "http://localhost:8000"
const api = axios.create({
baseURL: BASE_URL
})
export default api
@@ -0,0 +1,47 @@
import { createContext, useContext, useState, useEffect } from "react"
type User = {
id: number
username: string
role: string
}
type AuthContextType = {
user: User | null
login: (user: User) => void
logout: () => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
const storedUser = localStorage.getItem("user")
if (storedUser) {
setUser(JSON.parse(storedUser))
}
}, [])
const login = (userData: User) => {
setUser(userData)
localStorage.setItem("user", JSON.stringify(userData))
}
const logout = () => {
setUser(null)
localStorage.removeItem("user")
}
return <AuthContext.Provider value={{ user, login, logout }}>{children}</AuthContext.Provider>
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider")
}
return context
}
@@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,83 @@
import axios from "axios"
import type { Device } from "@/types/device"
import { getApiUrl } from "@/config/api"
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: any) => {
const filesResponse = await axios.get(getApiUrl(`get_file_by_device/${device.id}`))
const deviceResponse = await axios.get(getApiUrl(`get_device_by_id/${device.id}`))
const deviceData = deviceResponse.data
const filesData = 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(deviceData.created).toLocaleDateString("de-DE"),
files: filesData,
}
}),
)
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: any) => ({
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: any) => ({
value: String(type.id),
label: type.device_type,
}))
} catch (error) {
console.error("Failed to fetch device types:", error)
return []
}
}
export const addDevice = async (newDevice: {
device_name: string
device_type_id: string
manufacturer_id: string
serial: string
comment: string
}) => {
try {
await axios.post(getApiUrl("create_device"), newDevice)
} catch (error) {
console.error("Failed to add device:", error)
throw error
}
}
export const deleteDevice = async (deviceId: string) => {
try {
await axios.delete(getApiUrl(`delete_device/${deviceId}`))
} catch (error) {
console.error("Failed to delete device:", error)
throw error
}
}
@@ -0,0 +1,31 @@
"use server"
import { writeFile, mkdir } from "fs/promises"
import path from "path"
export async function uploadFile(formData: FormData) {
const file = formData.get("file") as File
const buffer = Buffer.from(await file.arrayBuffer())
const filename = file.name
// Primary location: Next.js public folder (for downloads/reference in the frontend)
const frontendUploadDir = path.join(process.cwd(), "public", "filesAttachment")
// Secondary location: central repo directory "files" (preferentially read by the Django backend)
const backendFilesDir = path.join(process.cwd(), "..", "..", "files")
try {
// Ensure directories exist
await mkdir(frontendUploadDir, { recursive: true })
await mkdir(backendFilesDir, { recursive: true })
// Write to both storage locations so frontend and backend have access
await writeFile(path.join(frontendUploadDir, filename), buffer)
await writeFile(path.join(backendFilesDir, filename), buffer)
return { success: true, filename }
} catch (error) {
console.error("Error saving file:", error)
return { success: false, error: "Failed to save the file" }
}
}
@@ -0,0 +1,21 @@
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"
firmware?: string
ipAddress?: string
}
@@ -0,0 +1,84 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}