diff --git a/netmanager-app/app/(authenticated)/clients/page.tsx b/netmanager-app/app/(authenticated)/clients/page.tsx new file mode 100644 index 0000000000..224632669a --- /dev/null +++ b/netmanager-app/app/(authenticated)/clients/page.tsx @@ -0,0 +1,290 @@ +"use client" + +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { ActivateClientDialog, DeactivateClientDialog } from "../../../components/clients/dialogs" +import { getClientsApi, activateUserClientApi } from "@/core/apis/analytics" +import { useToast } from "@/components/ui/use-toast" +import type { Client } from "@/app/types/clients" +import { Search, ArrowUpDown, Loader2 } from "lucide-react" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination" + +const ITEMS_PER_PAGE = 8 + +const formatDate = (dateString: string | undefined): string => { + if (!dateString) return "N/A" + const date = new Date(dateString) + if (isNaN(date.getTime())) return "Invalid Date" + + const now = new Date() + const diffTime = date.getTime() - now.getTime() + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + + if (diffDays < 0) return "Expired" + if (diffDays === 0) return "Expires today" + if (diffDays === 1) return "Expires tomorrow" + return `Expires in ${diffDays} days` +} + + +const ClientManagement = () => { + const [clients, setClients] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedClient, setSelectedClient] = useState(null) + const [activateDialogOpen, setActivateDialogOpen] = useState(false) + const [deactivateDialogOpen, setDeactivateDialogOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [currentPage, setCurrentPage] = useState(1) + const [sortField, setSortField] = useState("name") + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc") + const { toast } = useToast() + + const fetchClients = async () => { + setLoading(true) + try { + const response = await getClientsApi() + setClients(response.clients) + } catch (error) { + toast({ + title: "Error", + description: "Failed to fetch clients", + variant: "destructive", + }) + } finally { + setLoading(false) + } + } + + const handleCopyClientId = (clientId: string) => { + navigator.clipboard.writeText(clientId) + toast({ + title: "Client ID Copied", + description: "The client ID has been copied to your clipboard.", + }) + } + + useEffect(() => { + fetchClients() + }, []) + + const handleActivateDeactivate = async (clientId: string, activate: boolean) => { + try { + await activateUserClientApi({ _id: clientId, isActive: activate }) + await fetchClients() + toast({ + title: "Success", + description: `Client ${activate ? "activated" : "deactivated"} successfully`, + }) + } catch (error) { + toast({ + title: "Error", + description: `Failed to ${activate ? "activate" : "deactivate"} client`, + variant: "destructive", + }) + } finally { + setActivateDialogOpen(false) + setDeactivateDialogOpen(false) + setSelectedClient(null) + } + } + + const handleActivateClick = (client: Client) => { + setSelectedClient(client) + setActivateDialogOpen(true) + } + + const handleDeactivateClick = (client: Client) => { + setSelectedClient(client) + setDeactivateDialogOpen(true) + } + + const handleSort = (field: keyof Client) => { + if (sortField === field) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc") + } else { + setSortField(field) + setSortOrder("asc") + } + } + + const filteredClients = clients.filter( + (client) => + client.name.toLowerCase().includes(searchQuery.toLowerCase()) || + client.user.email.toLowerCase().includes(searchQuery.toLowerCase()), + ) + + const sortedClients = [...filteredClients].sort((a, b) => { + if (a[sortField] < b[sortField]) return sortOrder === "asc" ? -1 : 1 + if (a[sortField] > b[sortField]) return sortOrder === "asc" ? 1 : -1 + return 0 + }) + + const totalPages = Math.ceil(sortedClients.length / ITEMS_PER_PAGE) + const paginatedClients = sortedClients.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE) + + const activatedClients = clients.filter((client) => client.isActive).length + const deactivatedClients = clients.filter((client) => !client.isActive).length + + + + return ( +
+ {loading ? ( +
+ +
+ ) : ( + <> +
+

Client Management

+ +
+ +
+
+

Activated Clients

+

{activatedClients}

+
+
+

Deactivated Clients

+

{deactivatedClients}

+
+ +
+ +
+
+ + setSearchQuery(e.target.value)} + /> +
+ + + + + + handleSort("name")}> + Name {sortField === "name" && (sortOrder === "asc" ? "↑" : "↓")} + + handleSort("isActive")}> + Status {sortField === "isActive" && (sortOrder === "asc" ? "↑" : "↓")} + + + +
+ +
+ + + + Client Name + User Email + Token Expiry + Status + Actions + + + + {paginatedClients.map((client) => ( + + +
{client.name}
+
{client._id}
+
+ {client.user.email} + + {client.access_token?.expires + ? formatDate(client.access_token.expires) + : "N/A"} + + + + {client.isActive ? "Activated" : "Not Activated"} + + + + + + +
+ ))} +
+
+
+ +
+ + + + setCurrentPage((prev) => Math.max(prev - 1, 1))} + className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + setCurrentPage(page)} isActive={currentPage === page}> + {page} + + + ))} + + setCurrentPage((prev) => Math.min(prev + 1, totalPages))} + className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + +
+ + selectedClient && handleActivateDeactivate(selectedClient._id, true)} + clientName={selectedClient?.name} + /> + + selectedClient && handleActivateDeactivate(selectedClient._id, false)} + clientName={selectedClient?.name} + /> + + )} +
+ ) +} + +export default ClientManagement + diff --git a/netmanager-app/app/types/clients.ts b/netmanager-app/app/types/clients.ts new file mode 100644 index 0000000000..9e0235a51c --- /dev/null +++ b/netmanager-app/app/types/clients.ts @@ -0,0 +1,27 @@ +import { UserDetails } from './users'; + + export interface AccessToken { + _id: string + permissions: string[] + scopes: string[] + expiredEmailSent: boolean + token: string + client_id: string + name: string + expires: string + createdAt: string + updatedAt: string + __v: number + } + + export interface Client { + _id: string + isActive: boolean + ip_addresses: string[] + name: string + client_secret: string + user: UserDetails + access_token: AccessToken + } + + \ No newline at end of file diff --git a/netmanager-app/components/clients/dialogs.tsx b/netmanager-app/components/clients/dialogs.tsx new file mode 100644 index 0000000000..9cfd7d51b1 --- /dev/null +++ b/netmanager-app/components/clients/dialogs.tsx @@ -0,0 +1,56 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +interface DialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + clientName?: string +} + +export function ActivateClientDialog({ open, onOpenChange, onConfirm, clientName }: DialogProps) { + return ( + + + + Activate Client + + Are you sure you want to activate the client {clientName}? This action cannot be undone. + + + + Cancel + Activate + + + + ) +} + +export function DeactivateClientDialog({ open, onOpenChange, onConfirm, clientName }: DialogProps) { + return ( + + + + Deactivate Client + + Are you sure you want to deactivate the client {clientName}? This action cannot be undone. + + + + Cancel + Deactivate + + + + ) +} + diff --git a/netmanager-app/components/sidebar.tsx b/netmanager-app/components/sidebar.tsx index c419bf1987..2dc2b65a10 100644 --- a/netmanager-app/components/sidebar.tsx +++ b/netmanager-app/components/sidebar.tsx @@ -18,6 +18,7 @@ import { Map, ChevronDown, Check, + Hospital, PlusCircle, MonitorSmartphone, LogOut, @@ -210,6 +211,21 @@ const Sidebar = () => { + +
  • + + + Clients + +
  • +
  • , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/netmanager-app/components/ui/button.tsx b/netmanager-app/components/ui/button.tsx index f2329bdfe6..36496a2872 100644 --- a/netmanager-app/components/ui/button.tsx +++ b/netmanager-app/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; +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"; +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-colors 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", @@ -31,26 +31,26 @@ const buttonVariants = cva( size: "default", }, } -); +) export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { - asChild?: boolean; + asChild?: boolean } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot : "button" return ( - ); + ) } -); -Button.displayName = "Button"; +) +Button.displayName = "Button" -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/netmanager-app/core/apis/analytics.ts b/netmanager-app/core/apis/analytics.ts index 9f189c4cbc..b760d9ab6a 100644 --- a/netmanager-app/core/apis/analytics.ts +++ b/netmanager-app/core/apis/analytics.ts @@ -1,5 +1,5 @@ import createAxiosInstance from "./axiosConfig"; -import { ANALYTICS_MGT_URL } from "@/core/urls"; +import { ANALYTICS_MGT_URL, USERS_MGT_URL } from "@/core/urls"; const axiosInstance = createAxiosInstance(); @@ -26,3 +26,13 @@ export const dataExport = async (data: DataExportForm) => { { headers } ); }; + +export const getClientsApi = async () => { + return axiosInstance.get(`${USERS_MGT_URL}/clients`).then((response) => response.data); +}; + +export const activateUserClientApi = async (data: { _id: string; isActive: boolean }) => { + return axiosInstance + .put(`${USERS_MGT_URL}/clients/activate`, data) + .then((response) => response.data); +}; diff --git a/netmanager-app/package-lock.json b/netmanager-app/package-lock.json index 70377ac0f0..81afcd13ee 100644 --- a/netmanager-app/package-lock.json +++ b/netmanager-app/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", @@ -513,6 +514,33 @@ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.5.tgz", + "integrity": "sha512-1Y2sI17QzSZP58RjGtrklfSGIf3AF7U/HkD3aAcAnhOUJrm7+7GG1wRDFaUlSe0nW5B/t4mYd/+7RNbP2Wexug==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.5", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", @@ -643,14 +671,14 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz", - "integrity": "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.5.tgz", + "integrity": "sha512-LaO3e5h/NOEL4OfXjxD43k9Dx+vn+8n+PCFt6uhX/BADFflllyv3WJG6rgvvSVBxpTch938Qq/LGc2MMxipXPw==", "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", @@ -659,8 +687,34 @@ "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.6.1" + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.4.tgz", + "integrity": "sha512-XDUI0IVYVSwjMXxM6P4Dfti7AH+Y4oS/TB+sglZ/EXc7cqLwGAmp1NlMrcUjj7ks6R5WTZuWKv44FBbLpwU3sA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", diff --git a/netmanager-app/package.json b/netmanager-app/package.json index ac64760639..507d6a40b7 100644 --- a/netmanager-app/package.json +++ b/netmanager-app/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4",