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]: Profile Settings Page #2401

Merged
merged 35 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7314367
Profile Settings
danielmarv Jan 23, 2025
efeceff
User Details
danielmarv Jan 23, 2025
99c47c8
Remove commented-out code from PasswordEdit component
danielmarv Jan 28, 2025
5bfa9b7
commit
danielmarv Jan 28, 2025
dabe3f0
commit
danielmarv Jan 28, 2025
5992733
commit
danielmarv Jan 28, 2025
36741ae
Refactor ApiTokens component to fetch user clients and display them i…
danielmarv Jan 28, 2025
4dac54e
Implement client token creation and enhance client fetching in ApiTok…
danielmarv Jan 28, 2025
3c4ea58
commit
danielmarv Jan 29, 2025
cc432de
Add clients slice and hooks for managing client data; integrate Radix…
danielmarv Jan 29, 2025
91afbf1
Merge remote-tracking branch 'origin/staging' into Daniel-Net
danielmarv Jan 30, 2025
6d5b0d6
Add Clients tab to profile page and adjust layout
danielmarv Jan 30, 2025
d97fc00
Add permission-based visibility for Clients tab in profile
danielmarv Jan 30, 2025
5a82718
Fix client email display to handle undefined user cases
danielmarv Jan 30, 2025
5ff6a08
Refactor client activation API call to use POST method and update dat…
danielmarv Feb 1, 2025
6375ae9
Remove "Copy ID" button from client management table
danielmarv Feb 1, 2025
cf77a7f
Remove unused handleCopyClientId function from ClientManagement compo…
danielmarv Feb 1, 2025
4ce1cc3
Enhance MyProfile component with improved user data fetching and erro…
danielmarv Feb 1, 2025
6cb5db2
Enhance MyProfile component with loading indicator and error display;…
danielmarv Feb 1, 2025
5307a67
Add Cloudinary image upload API and update user details API; refactor…
danielmarv Feb 1, 2025
c0f547d
Implement password change functionality with validation and strength …
danielmarv Feb 2, 2025
71ff5a8
Refactor updateUserPasswordApi to use query parameters for user ID; a…
danielmarv Feb 2, 2025
845c0fa
Merge remote-tracking branch 'origin/staging' into Daniel-Net
danielmarv Feb 2, 2025
48d88c9
commit
danielmarv Feb 4, 2025
83a4cfb
Refactor token generation error handling and remove unnecessary toast…
danielmarv Feb 4, 2025
d635349
Refactor API imports in settings components to use a centralized sett…
danielmarv Feb 5, 2025
041bafd
Rename "Password Edit" tab to "Reset Password" for improved clarity
danielmarv Feb 5, 2025
84907a3
Refactor token generation and retrieval logic for improved error hand…
danielmarv Feb 5, 2025
bb70faf
fix pagination
Codebmk Feb 5, 2025
9887f72
fix update button
Codebmk Feb 5, 2025
6680208
Enhance API token management by improving access token checks and add…
danielmarv Feb 5, 2025
882aa7b
Merge branch 'Daniel-Net' of https://github.com/airqo-platform/AirQo-…
danielmarv Feb 5, 2025
0318786
Add ClientsDetails interface and improve access token display in User…
danielmarv Feb 5, 2025
4aff39c
Remove unused ClientsDetails interface from clients.ts
danielmarv Feb 5, 2025
faef2c6
Refactor UserClientsTable to improve access token handling and add us…
danielmarv Feb 6, 2025
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
282 changes: 282 additions & 0 deletions netmanager-app/app/(authenticated)/profile/components/ApiTokens.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import type React from "react"
import { useEffect, useState } from "react"
import moment from "moment"
import { useAppDispatch, useAppSelector } from "@/core/redux/hooks"
import { Toast } from "@/components/ui/toast"
import { Button } from "@/components/ui/button"
import { performRefresh } from "@/lib/store/services/apiClient"
import EditClientForm from "./EditClientForm"
import DialogWrapper from "./DialogWrapper"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { Edit, Copy, Info } from "lucide-react"
import { api } from "../utils/api"tore"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix syntax error in import statement.

There's a typo in the import statement that would prevent compilation.

-import { api } from "../utils/api"tore"
+import { api } from "../utils/api"

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Biome (1.9.4)

[error] 13-13: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 13-13: unterminated string literal

The closing quote must be on the same line.

(parse)


const UserClientsTable: React.FC = () => {
const dispatch = useAppDispatch()
const [isError, setIsError] = useState({ isError: false, message: "", type: "" })
const [isActivationRequestError, setIsActivationRequestError] = useState({ isError: false, message: "", type: "" })
const [showInfoModal, setShowInfoModal] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingToken, setIsLoadingToken] = useState(false)
const [isLoadingActivationRequest, setIsLoadingActivationRequest] = useState(false)
const [openEditForm, setOpenEditForm] = useState(false)
const [selectedClient, setSelectedClient] = useState(null)
const userInfo = useAppSelector((state) => state.user.userDetails)
const clients = useAppSelector((state) => state.sites.clients)
const clientsDetails = useAppSelector((state) => state.sites.clientsDetails)
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 4

const onPageChange = (newPage: number) => {
setCurrentPage(newPage)
}

const setErrorState = (message: string, type: string) => {
setIsError({
isError: true,
message,
type,
})
}

useEffect(() => {
const fetchData = async () => {
setIsLoading(true)
try {
const res = await api.getUserDetailsAccount(userInfo?._id)
if (res.success === true) {
dispatch({ type: "ADD_CLIENTS", payload: res.users[0].clients })
setCurrentPage(1)
}
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [userInfo?._id, dispatch])

useEffect(() => {
const fetchData = async () => {
setIsLoading(true)
try {
const response = await api.getClients(userInfo?._id)
if (response.success === true) {
dispatch({ type: "ADD_CLIENTS_DETAILS", payload: response.clients })
}
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [userInfo?._id, dispatch])

const hasAccessToken = (clientId: string) => {
const client =
Array.isArray(clientsDetails) && (clientsDetails)
? clientsDetails?.find((client: any) => client._id === clientId)
: undefined
return client && client.access_token
}

const getClientToken = (clientID: string) => {
const client =
Array.isArray(clientsDetails) && (clientsDetails)
? clientsDetails?.find((client: any) => client._id === clientID)
: undefined
return client && client.access_token && client.access_token.token
}

const getClientTokenExpiryDate = (clientID: string) => {
const client =
Array.isArray(clientsDetails) && (clientsDetails)
? clientsDetails?.find((client: any) => client._id === clientID)
: undefined
return client && client.access_token && client.access_token.expires
}

const handleGenerateToken = async (res: any) => {
setIsLoadingToken(true)
if (!res?.isActive) {
setShowInfoModal(true)
setIsLoadingToken(false)
} else {
try {
const response = await api.generateToken(res)
if (response.success === true) {
setErrorState("Token generated", "success")
}
dispatch(performRefresh())
} catch (error: any) {
setErrorState(error.message, "error")
} finally {
setIsLoadingToken(false)
}
}
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Improve error handling in token generation.

The handleGenerateToken function could benefit from more specific error handling and user feedback.

   const handleGenerateToken = async (res: any) => {
     setIsLoadingToken(true)
     if (!res?.isActive) {
       setShowInfoModal(true)
       setIsLoadingToken(false)
     } else {
       try {
         const response = await api.generateToken(res)
         if (response.success === true) {
           setErrorState("Token generated", "success")
+          await dispatch(performRefresh())
         }
-        dispatch(performRefresh())
       } catch (error: any) {
-        setErrorState(error.message, "error")
+        const errorMessage = error.response?.data?.message || error.message || "Failed to generate token"
+        setErrorState(errorMessage, "error")
+        console.error("Token generation failed:", error)
       } finally {
         setIsLoadingToken(false)
       }
     }
   }
📝 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 handleGenerateToken = async (res: any) => {
setIsLoadingToken(true)
if (!res?.isActive) {
setShowInfoModal(true)
setIsLoadingToken(false)
} else {
try {
const response = await api.generateToken(res)
if (response.success === true) {
setErrorState("Token generated", "success")
}
dispatch(performRefresh())
} catch (error: any) {
setErrorState(error.message, "error")
} finally {
setIsLoadingToken(false)
}
}
}
const handleGenerateToken = async (res: any) => {
setIsLoadingToken(true)
if (!res?.isActive) {
setShowInfoModal(true)
setIsLoadingToken(false)
} else {
try {
const response = await api.generateToken(res)
if (response.success === true) {
setErrorState("Token generated", "success")
await dispatch(performRefresh())
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || "Failed to generate token"
setErrorState(errorMessage, "error")
console.error("Token generation failed:", error)
} finally {
setIsLoadingToken(false)
}
}
}


const handleActivationRequest = async () => {
const setActivationRequestErrorState = (message: string, type: string) => {
setIsActivationRequestError({
isError: true,
message,
type,
})
}
setIsLoadingActivationRequest(true)
try {
const clientID = selectedClient?._id
const response = await api.activationRequest(clientID)
if (response.success === true) {
setShowInfoModal(false)
setTimeout(() => {
setActivationRequestErrorState("Activation request sent successfully", "success")
}, 3000)
}
} catch (error: any) {
setShowInfoModal(false)
setTimeout(() => {
setActivationRequestErrorState(error.message, "error")
}, 3000)
} finally {
setIsLoadingActivationRequest(false)
}
}

const displayIPAddresses = (client: any) => {
return Array.isArray(client.ip_addresses) ? client.ip_addresses.join(", ") : client.ip_addresses
}

return (
<div className="overflow-x-auto">
{isError.isError && <Toast type={isError.type} message={isError.message} />}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Client name</TableHead>
<TableHead className="w-[138px]">IP Address</TableHead>
<TableHead className="w-[142px]">Client Status</TableHead>
<TableHead className="w-[138px]">Created</TableHead>
<TableHead className="w-[138px]">Token</TableHead>
<TableHead className="w-[138px]">Expires</TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7}>
<Skeleton className="w-full h-12" />
</TableCell>
</TableRow>
) : clients?.length > 0 ? (
clients
.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
.map((client: any, index: number) => (
<TableRow key={index}>
<TableCell className="font-medium">{client?.name}</TableCell>
<TableCell>{displayIPAddresses(client)}</TableCell>
<TableCell>
<span
className={`px-2 py-1 rounded-full text-xs ${
client?.isActive ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{client?.isActive ? "Activated" : "Not Activated"}
</span>
</TableCell>
<TableCell>{moment(client?.createdAt).format("MMM DD, YYYY")}</TableCell>
<TableCell>
{getClientToken(client._id) ? (
<div className="flex items-center">
<span className="mr-2">
{getClientToken(client._id).slice(0, 2)}....
{getClientToken(client._id).slice(-2)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(getClientToken(client._id))
setErrorState("Token copied to clipboard!", "success")
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
) : (
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Enhance token display security and UX.

The current token display implementation could be improved for better security and user experience.

   <TableCell>
     {getClientToken(client._id) ? (
       <div className="flex items-center">
-        <span className="mr-2">
-          {getClientToken(client._id).slice(0, 2)}....
-          {getClientToken(client._id).slice(-2)}
-        </span>
+        <span className="mr-2 font-mono">
+          {getClientToken(client._id).slice(0, 4)}
+          <span className="mx-1">•••••••</span>
+          {getClientToken(client._id).slice(-4)}
+        </span>
         <Button
           variant="ghost"
           size="sm"
           onClick={() => {
             navigator.clipboard.writeText(getClientToken(client._id))
-            setErrorState("Token copied to clipboard!", "success")
+            setErrorState("API token copied to clipboard", "success")
           }}
+          title="Copy full token"
         >
           <Copy className="h-4 w-4" />
         </Button>
       </div>
📝 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
{getClientToken(client._id) ? (
<div className="flex items-center">
<span className="mr-2">
{getClientToken(client._id).slice(0, 2)}....
{getClientToken(client._id).slice(-2)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(getClientToken(client._id))
setErrorState("Token copied to clipboard!", "success")
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
) : (
{getClientToken(client._id) ? (
<div className="flex items-center">
<span className="mr-2 font-mono">
{getClientToken(client._id).slice(0, 4)}
<span className="mx-1">•••••••</span>
{getClientToken(client._id).slice(-4)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(getClientToken(client._id))
setErrorState("API token copied to clipboard", "success")
}}
title="Copy full token"
>
<Copy className="h-4 w-4" />
</Button>
</div>
) : (

<Button
variant={!hasAccessToken(client._id) ? "default" : "secondary"}
size="sm"
disabled={isLoadingToken}
onClick={() => {
const res = {
name: client.name,
client_id: client._id,
isActive: client.isActive ? client.isActive : false,
}
setSelectedClient(client)
handleGenerateToken(res)
}}
>
Generate
</Button>
)}
</TableCell>
<TableCell>
{getClientTokenExpiryDate(client._id) &&
moment(getClientTokenExpiryDate(client._id)).format("MMM DD, YYYY")}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOpenEditForm(true)
setSelectedClient(client)
}}
>
<Edit className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center">
No data found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Add your Pagination component here */}
Copy link

@coderabbitai coderabbitai bot Jan 29, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue

Implement pagination component.

The comment indicates a missing pagination component. This is essential for proper table navigation.

-      {/* Add your Pagination component here */}
+      {clients?.length > itemsPerPage && (
+        <div className="flex justify-center mt-4">
+          <div className="flex space-x-2">
+            {Array.from({ length: Math.ceil(clients.length / itemsPerPage) }).map((_, index) => (
+              <Button
+                key={index}
+                variant={currentPage === index + 1 ? "default" : "outline"}
+                size="sm"
+                onClick={() => onPageChange(index + 1)}
+              >
+                {index + 1}
+              </Button>
+            ))}
+          </div>
+        </div>
+      )}

Committable suggestion skipped: line range outside the PR's diff.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hi @danielmarv , I hope you have been able to review this suggestion?

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

{isActivationRequestError.isError && (
<Toast type={isActivationRequestError.type} message={isActivationRequestError.message} />
)}
<EditClientForm open={openEditForm} closeModal={() => setOpenEditForm(false)} data={selectedClient} />
<DialogWrapper
open={showInfoModal}
onClose={() => setShowInfoModal(false)}
handleClick={handleActivationRequest}
primaryButtonText={"Send activation request"}
loading={isLoadingActivationRequest}
>
<div className="flex items-center">
<Info className="h-5 w-5 mr-2 text-blue-500" />
<p className="text-sm text-gray-600">
You cannot generate a token for an inactive client. Reach out to support for assistance at [email protected]
or send an activation request.
</p>
</div>
</DialogWrapper>
</div>
)
}

export default UserClientsTable

Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { FC } from "react"
import moment from "moment"
import {Button} from "@/components/ui/button"
import CopyIcon from "@/icons/Common/copy.svg"
import EditIcon from "@/icons/Common/edit-pencil.svg"

interface ClientTableRowProps {
client: any
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 'any' type with a proper Client interface.

Using 'any' type reduces type safety and IDE support. Consider using the Client interface that's already defined in your types.

-  client: any
+  client: Client

Committable suggestion skipped: line range outside the PR's diff.

onGenerateToken: (client: any) => void
onEditClient: (client: any) => void
onCopyToken: (token: string) => void
getClientToken: (clientId: string) => string | null
getClientTokenExpiryDate: (clientId: string) => string | null
isLoadingToken: boolean
}

export const ClientTableRow: FC<ClientTableRowProps> = ({
client,
onGenerateToken,
onEditClient,
onCopyToken,
getClientToken,
getClientTokenExpiryDate,
isLoadingToken,
}) => {
const displayIPAddresses = (client) => {
return Array.isArray(client.ip_addresses) ? client.ip_addresses.join(", ") : client.ip_addresses
}
Copy link

@coderabbitai coderabbitai bot Jan 29, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add IP address format validation.

The current implementation doesn't validate IP address formats. Consider adding validation to ensure proper IP address formatting.

 const displayIPAddresses = (client) => {
+  const isValidIP = (ip: string) => {
+    const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
+    return ipv4Regex.test(ip) && ip.split('.').every(num => parseInt(num) <= 255);
+  };
+  const addresses = Array.isArray(client.ip_addresses) ? client.ip_addresses : [client.ip_addresses];
+  return addresses.filter(ip => ip && isValidIP(ip)).join(", ") || "Invalid IP";
-  return Array.isArray(client.ip_addresses) ? client.ip_addresses.join(", ") : client.ip_addresses
 }
📝 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 displayIPAddresses = (client) => {
return Array.isArray(client.ip_addresses) ? client.ip_addresses.join(", ") : client.ip_addresses
}
const displayIPAddresses = (client) => {
const isValidIP = (ip: string) => {
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
return ipv4Regex.test(ip) && ip.split('.').every(num => parseInt(num) <= 255);
};
const addresses = Array.isArray(client.ip_addresses) ? client.ip_addresses : [client.ip_addresses];
return addresses.filter(ip => ip && isValidIP(ip)).join(", ") || "Invalid IP";
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

@danielmarv ,please consider this suggestion

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


return (
<tr className="border-b border-b-secondary-neutral-light-100">
<td className="w-[200px] px-4 py-3 font-medium text-sm leading-5 text-secondary-neutral-light-800 uppercase">
{client?.name}
</td>
<td className="w-[138px] px-4 py-3 font-medium text-sm leading-5 text-secondary-neutral-light-400">
{displayIPAddresses(client)}
</td>
<td className="w-[142px] px-4 py-3">
<div
className={`px-2 py-[2px] rounded-2xl w-auto inline-flex justify-center text-sm leading-5 items-center mx-auto ${
client?.isActive
? "bg-success-50 text-success-700"
: "bg-secondary-neutral-light-50 text-secondary-neutral-light-500"
}`}
>
{client?.isActive ? "Activated" : "Not Activated"}
</div>
</td>
<td className="w-[138px] px-4 py-3 font-medium text-sm leading-5 text-secondary-neutral-light-400">
{moment(client?.createdAt).format("MMM DD, YYYY")}
</td>
<td className="w-[138px] px-4 py-3">
{getClientToken(client._id) ? (
<span className="font-medium text-sm leading-5 text-secondary-neutral-light-400 flex items-center gap-2">
{getClientToken(client._id).slice(0, 2)}....
{getClientToken(client._id).slice(-2)}
<div
className="w-6 h-6 bg-white rounded border border-gray-200 flex justify-center items-center gap-2 cursor-pointer"
onClick={() => onCopyToken(getClientToken(client._id))}
>
<CopyIcon />
</div>
</span>
) : (
<Button
title={!client?.isActive ? "Tap to generate token" : "Token already generated"}
className={`px-4 py-2 rounded-2xl w-auto inline-flex justify-center text-sm leading-5 items-center mx-auto ${
!getClientToken(client._id)
? "bg-success-700 text-success-50 cursor-pointer"
: "bg-secondary-neutral-light-50 text-secondary-neutral-light-500"
}`}
disabled={isLoadingToken}
onClick={() => onGenerateToken(client)}
>
Generate
</Button>
)}
</td>
<td className="w-[138px] px-4 py-3 font-medium text-sm leading-5 text-secondary-neutral-light-400">
{getClientTokenExpiryDate(client._id) && moment(getClientTokenExpiryDate(client._id)).format("MMM DD, YYYY")}
</td>
<td className="w-24 px-4 py-3 font-medium text-sm leading-5 text-secondary-neutral-light-400 capitalize">
<div
className="w-9 h-9 p-2.5 bg-white rounded border border-gray-200 justify-center items-center gap-2 cursor-pointer"
onClick={() => onEditClient(client)}
>
<EditIcon className="w-4 h-4" />
</div>
</td>
</tr>
)
}

Loading