v4.5 Demo initial commit
@@ -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
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 875 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 924 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
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"]
|
||||
}
|
||||