Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Netmanager]: Clients page with permissions restrictions #2405

Merged
merged 7 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 290 additions & 0 deletions netmanager-app/app/(authenticated)/clients/page.tsx
Original file line number Diff line number Diff line change
@@ -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`
}
Comment on lines +24 to +37
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace unsafe isNaN with Number.isNaN

The current implementation uses the unsafe global isNaN which performs type coercion. Use Number.isNaN instead for more predictable type checking.

 const formatDate = (dateString: string | undefined): string => {
   if (!dateString) return "N/A"
   const date = new Date(dateString)
-  if (isNaN(date.getTime())) return "Invalid Date"
+  if (Number.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))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 formatDate = (dateString: string | undefined): string => {
if (!dateString) return "N/A"
const date = new Date(dateString)
if (Number.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`
}
🧰 Tools
🪛 Biome (1.9.4)

[error] 27-27: isNaN is unsafe. It attempts a type coercion. Use Number.isNaN instead.

See the MDN documentation for more details.
Unsafe fix: Use Number.isNaN instead.

(lint/suspicious/noGlobalIsNan)



const ClientManagement = () => {
const [clients, setClients] = useState<Client[]>([])
const [loading, setLoading] = useState(false)
const [selectedClient, setSelectedClient] = useState<Client | null>(null)
const [activateDialogOpen, setActivateDialogOpen] = useState(false)
const [deactivateDialogOpen, setDeactivateDialogOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [sortField, setSortField] = useState<keyof Client>("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")
}
}
Comment on lines +111 to +118
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add type safety to sorting function

The current sorting implementation might fail for nested properties like user.email. Consider adding type safety and proper handling for nested properties.

-  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 sortedClients = [...filteredClients].sort((a, b) => {
+    const getValue = (obj: Client, field: keyof Client) => {
+      if (field === "user.email") return obj.user.email
+      return obj[field]
+    }
+    const aValue = getValue(a, sortField)
+    const bValue = getValue(b, sortField)
+    return sortOrder === "asc" 
+      ? String(aValue).localeCompare(String(bValue))
+      : String(bValue).localeCompare(String(aValue))
+  })

Also applies to: 126-130


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 (
<div className="container mx-auto py-10">
{loading ? (
<div className="flex justify-center items-center h-64">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Client Management</h1>
<Button onClick={fetchClients}>Refresh</Button>
</div>

<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-green-100 p-4 rounded-lg">
<h2 className="text-lg font-semibold">Activated Clients</h2>
<p className="text-3xl font-bold">{activatedClients}</p>
</div>
<div className="bg-red-100 p-4 rounded-lg">
<h2 className="text-lg font-semibold">Deactivated Clients</h2>
<p className="text-3xl font-bold">{deactivatedClients}</p>
</div>

</div>

<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search clients..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-2">
Sort by <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleSort("name")}>
Name {sortField === "name" && (sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSort("isActive")}>
Status {sortField === "isActive" && (sortOrder === "asc" ? "↑" : "↓")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Client Name</TableHead>
<TableHead>User Email</TableHead>
<TableHead>Token Expiry</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedClients.map((client) => (
<TableRow key={client._id}>
<TableCell>
<div className="font-medium">{client.name}</div>
<div className="text-sm text-muted-foreground">{client._id}</div>
</TableCell>
<TableCell>{client.user.email}</TableCell>
<TableCell>
{client.access_token?.expires
? formatDate(client.access_token.expires)
: "N/A"}
</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
client.isActive
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{client.isActive ? "Activated" : "Not Activated"}
</span>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
onClick={() => (client.isActive ? handleDeactivateClick(client) : handleActivateClick(client))}
>
{client.isActive ? "Deactivate" : "Activate"}
</Button>
<Button variant="ghost" onClick={() => handleCopyClientId(client._id)}>
Copy ID
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>

<div className="mt-4 flex justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink onClick={() => setCurrentPage(page)} isActive={currentPage === page}>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>

<ActivateClientDialog
open={activateDialogOpen}
onOpenChange={setActivateDialogOpen}
onConfirm={() => selectedClient && handleActivateDeactivate(selectedClient._id, true)}
clientName={selectedClient?.name}
/>

<DeactivateClientDialog
open={deactivateDialogOpen}
onOpenChange={setDeactivateDialogOpen}
onConfirm={() => selectedClient && handleActivateDeactivate(selectedClient._id, false)}
clientName={selectedClient?.name}
/>
</>
)}
</div>
)
}

export default ClientManagement

27 changes: 27 additions & 0 deletions netmanager-app/app/types/clients.ts
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +17 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Hash sensitive credentials for security

Storing client_secret in plain text could pose security risks if the data is ever exposed. It is a best practice to store such credentials as securely hashed values on the server side. Consider clarifying or documenting how client_secret is stored and used.



56 changes: 56 additions & 0 deletions netmanager-app/components/clients/dialogs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Activate Client</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to activate the client {clientName}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Activate</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

export function DeactivateClientDialog({ open, onOpenChange, onConfirm, clientName }: DialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deactivate Client</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to deactivate the client {clientName}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Deactivate</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

Loading
Loading