-
Notifications
You must be signed in to change notification settings - Fork 31
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
Changes from all commits
f9d6fd8
3cabe59
dde8e13
7bc0bc3
cf84b1a
e6fb3e0
2376cb4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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` | ||
} | ||
|
||
|
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 - 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 | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hash sensitive credentials for security Storing |
||
|
||
|
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> | ||
) | ||
} | ||
|
There was a problem hiding this comment.
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. UseNumber.isNaN
instead for more predictable type checking.📝 Committable suggestion
🧰 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)