diff --git a/dashboard/app/(private)/approvals/page.tsx b/dashboard/app/(private)/approvals/page.tsx new file mode 100644 index 0000000..d69596b --- /dev/null +++ b/dashboard/app/(private)/approvals/page.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + Calendar, + Check, + CheckCircle, + X, + XCircle, +} from "lucide-react"; +import type React from "react"; +import { useEffect, useState } from "react"; +import { + type Device, + useApproveDevice, + useGetDevices, + useRevokeDevice, +} from "../../api-client"; + +export default function ApprovalsPage() { + const queryClient = useQueryClient(); + const [processingDevices, setProcessingDevices] = useState>( + new Set(), + ); + const [toast, setToast] = useState<{ + message: string; + type: "success" | "error"; + } | null>(null); + + const unapprovedDevices = useGetDevices( + { approved: false }, + { query: { refetchInterval: 5000 } }, + ); + + const approveDeviceHook = useApproveDevice(); + const revokeDeviceHook = useRevokeDevice(); + + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + const formatTimeAgo = (dateString: string) => { + const now = new Date(); + const past = new Date(dateString); + const diff = now.getTime() - past.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + return `${minutes}m ago`; + }; + + const getDeviceHostname = (device: Device) => { + return device.system_info?.hostname || "No hostname"; + }; + + const handleApprove = async (deviceId: number, e: React.MouseEvent) => { + e.stopPropagation(); + + const device = unapprovedDevices.data?.find((d) => d.id === deviceId); + const deviceName = device?.serial_number || "Device"; + + setProcessingDevices((prev) => new Set(prev).add(deviceId)); + + const success = await approveDeviceHook.mutateAsync({ deviceId }); + + if (success) { + queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); + setToast({ + message: `${deviceName} approved successfully`, + type: "success", + }); + } else { + setToast({ + message: `Failed to approve ${deviceName}`, + type: "error", + }); + } + + setProcessingDevices((prev) => { + const newSet = new Set(prev); + newSet.delete(deviceId); + return newSet; + }); + }; + + const handleReject = async (deviceId: number, e: React.MouseEvent) => { + e.stopPropagation(); + + const device = unapprovedDevices.data?.find((d) => d.id === deviceId); + const deviceName = device?.serial_number || "Device"; + + if ( + !confirm( + `Are you sure you want to reject ${deviceName}? This will archive it.`, + ) + ) { + return; + } + + setProcessingDevices((prev) => new Set(prev).add(deviceId)); + + const success = await revokeDeviceHook.mutateAsync({ deviceId }); + + if (success) { + queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); + setToast({ + message: `${deviceName} rejected and archived`, + type: "success", + }); + } else { + setToast({ + message: `Failed to reject ${deviceName}`, + type: "error", + }); + } + + setProcessingDevices((prev) => { + const newSet = new Set(prev); + newSet.delete(deviceId); + return newSet; + }); + }; + + return ( + <> + {/* Toast Notification */} + {toast && ( +
+
+ {toast.type === "success" ? ( + + ) : ( + + )} + {toast.message} + +
+
+ )} + +
+
+

Pending Approvals

+

+ Review and approve or reject devices waiting for access +

+
+ +
+ {unapprovedDevices.isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+ ))} +
+ ) : unapprovedDevices.data?.length === 0 ? ( +
+ +

+ All caught up! +

+

+ No devices pending approval +

+
+ ) : ( +
+ {unapprovedDevices.data?.map((device) => ( +
+
+
+ +
+

+ {device.serial_number} +

+

+ {getDeviceHostname(device)} +

+
+ + {formatTimeAgo(device.created_on)} +
+
+
+
+ + +
+
+
+ ))} +
+ )} +
+
+ + ); +} diff --git a/dashboard/app/(private)/dashboard/page.tsx b/dashboard/app/(private)/dashboard/page.tsx index c8da33f..778ea2e 100644 --- a/dashboard/app/(private)/dashboard/page.tsx +++ b/dashboard/app/(private)/dashboard/page.tsx @@ -1,42 +1,25 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { - AlertCircle, AlertTriangle, - Calendar, - Check, CheckCircle, ChevronRight, Clock, Cpu, Package, - X, XCircle, } from "lucide-react"; import Link from "next/link"; -import type React from "react"; -import { useEffect, useState } from "react"; import NetworkQualityIndicator from "@/app/components/NetworkQualityIndicator"; import { useConfig } from "@/app/hooks/config"; import { type Device, - useApproveDevice, useGetDashboard, useGetDevices, - useRevokeDevice, } from "../../api-client"; const AdminPanel = () => { const { config } = useConfig(); - const queryClient = useQueryClient(); - const [processingDevices, setProcessingDevices] = useState>( - new Set(), - ); - const [toast, setToast] = useState<{ - message: string; - type: "success" | "error"; - } | null>(null); // Build exclude_labels query param const excludeLabels = @@ -48,11 +31,6 @@ const AdminPanel = () => { query: { refetchInterval: 5000 }, }); - const unapprovedDevices = useGetDevices( - { approved: false }, - { query: { refetchInterval: 5000 } }, - ); - const { data: outdatedDevices = [], isLoading: outdatedLoading } = useGetDevices( { @@ -72,22 +50,11 @@ const AdminPanel = () => { { query: { refetchInterval: 5000 } }, ); - const approveDeviceHook = useApproveDevice(); - const revokeDeviceHook = useRevokeDevice(); - const loading = dashboardQuery.isLoading || - unapprovedDevices.isLoading || outdatedLoading || offlineLoading; - useEffect(() => { - if (toast) { - const timer = setTimeout(() => setToast(null), 3000); - return () => clearTimeout(timer); - } - }, [toast]); - const getDeviceName = (device: Device) => device.serial_number; const formatTimeAgo = (dateString: string) => { @@ -107,80 +74,6 @@ const AdminPanel = () => { return `https://flagicons.lipis.dev/flags/4x3/${countryCode.toLowerCase()}.svg`; }; - const handleApprove = async (deviceId: number, e: React.MouseEvent) => { - e.stopPropagation(); - - const device = unapprovedDevices.data?.find((d) => d.id === deviceId); - const deviceName = device?.serial_number || "Device"; - - setProcessingDevices((prev) => new Set(prev).add(deviceId)); - - const success = await approveDeviceHook.mutateAsync({ deviceId }); - - if (success) { - queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); - queryClient.invalidateQueries({ queryKey: dashboardQuery.queryKey }); - setToast({ - message: `${deviceName} approved successfully`, - type: "success", - }); - } else { - setToast({ - message: `Failed to approve ${deviceName}`, - type: "error", - }); - } - - setProcessingDevices((prev) => { - const newSet = new Set(prev); - newSet.delete(deviceId); - return newSet; - }); - }; - - const handleReject = async (deviceId: number, e: React.MouseEvent) => { - e.stopPropagation(); - - const device = unapprovedDevices.data?.find((d) => d.id === deviceId); - const deviceName = device?.serial_number || "Device"; - - if ( - !confirm( - `Are you sure you want to reject ${deviceName}? This will archive it.`, - ) - ) { - return; - } - - setProcessingDevices((prev) => new Set(prev).add(deviceId)); - - const success = await revokeDeviceHook.mutateAsync({ deviceId }); - - if (success) { - queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); - queryClient.invalidateQueries({ queryKey: dashboardQuery.queryKey }); - setToast({ - message: `${deviceName} rejected and archived`, - type: "success", - }); - } else { - setToast({ - message: `Failed to reject ${deviceName}`, - type: "error", - }); - } - - setProcessingDevices((prev) => { - const newSet = new Set(prev); - newSet.delete(deviceId); - return newSet; - }); - }; - - const getDeviceHostname = (device: Device) => { - return device.system_info?.hostname || "No hostname"; - }; - // Sort by last_seen descending (most recent first), never seen at the end const sortByLastSeen = (a: Device, b: Device) => { if (!a.last_seen && !b.last_seen) return 0; @@ -252,37 +145,8 @@ const AdminPanel = () => { neverSeen.length > 0; return ( - <> - {/* Toast Notification */} - {toast && ( -
-
- {toast.type === "success" ? ( - - ) : ( - - )} - {toast.message} - -
-
- )} - -
- {/* Main Content */} -
- {/* Overview Stats */} +
+ {/* Overview Stats */}
{loading ? ( // Skeleton loading for stats @@ -381,7 +245,7 @@ const AdminPanel = () => { return (
@@ -462,7 +326,7 @@ const AdminPanel = () => { return (
@@ -527,7 +391,7 @@ const AdminPanel = () => { return (
@@ -592,7 +456,7 @@ const AdminPanel = () => { return (
@@ -657,7 +521,7 @@ const AdminPanel = () => { return (
@@ -721,92 +585,7 @@ const AdminPanel = () => {
)} -
- - {/* Right Sidebar - Unapproved Devices */} -
-
-
-
-

- Pending Approval -

- {!loading && (unapprovedDevices.data || []).length > 0 && ( - - {unapprovedDevices.data?.length || 0} device - {unapprovedDevices.data?.length !== 1 ? "s" : ""} - - )} -
-
-
- {loading ? ( -
- {[...Array(3)].map((_, i) => ( -
-
-
-
- ))} -
- ) : unapprovedDevices.data?.length === 0 ? ( -
- -

- All caught up! -

-

- No devices pending approval -

-
- ) : ( -
- {unapprovedDevices.data?.map((device) => ( -
-
-
- -
-

- {device.serial_number} -

-

- {getDeviceHostname(device)} -

-
-
-
-
- - {formatTimeAgo(device.created_on)} -
-
- - -
-
- ))} -
- )} -
-
-
-
- +
); }; diff --git a/dashboard/app/(private)/layout.tsx b/dashboard/app/(private)/layout.tsx index 5910eed..ec19eca 100644 --- a/dashboard/app/(private)/layout.tsx +++ b/dashboard/app/(private)/layout.tsx @@ -1,11 +1,12 @@ "use client"; -import { Cpu, FileText, Globe, Home, Layers, Smartphone } from "lucide-react"; +import { Bell, Cpu, FileText, Globe, Home, Layers, Smartphone } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import type React from "react"; import Profile from "@/app/components/profile"; +import { useGetDevices } from "../api-client"; export default function PrivateLayout({ children, @@ -13,6 +14,12 @@ export default function PrivateLayout({ children: React.ReactNode; }>) { const pathname = usePathname(); + const { data: unapprovedDevices } = useGetDevices( + { approved: false }, + { query: { refetchInterval: 5000 } }, + ); + const pendingCount = unapprovedDevices?.length || 0; + const navigationItems = [ { basePath: "/dashboard", label: "Dashboard", icon: Home }, { basePath: "/devices", label: "Devices", icon: Cpu }, @@ -42,7 +49,7 @@ export default function PrivateLayout({
{/* Logo */} {item.label} @@ -77,13 +84,29 @@ export default function PrivateLayout({
- {/* Right side - Docs and Profile */} + {/* Right side - Notifications, Docs and Profile */}
+ {/* Approvals Notification */} + + + {pendingCount > 0 && ( + + {pendingCount > 99 ? "99+" : pendingCount} + + )} + {/* Docs Link */} Docs @@ -107,7 +130,7 @@ export default function PrivateLayout({ active ? "text-gray-900 bg-gray-100" : "text-gray-600 hover:text-gray-900 hover:bg-gray-50" - } group flex items-center px-2 py-2 text-base font-medium rounded-md w-full transition-colors duration-200 cursor-pointer`} + } group flex items-center px-2 py-2 text-base font-medium rounded-md w-full transition-colors duration-200`} > {item.label}