From 68316510068f2901e949f692153d3d4c8a707ff8 Mon Sep 17 00:00:00 2001 From: Paul Ochieng Levi Date: Sun, 5 Jul 2026 22:15:47 +0300 Subject: [PATCH 1/6] feat: add user management features including user details page, user list page, and role assignment functionality - Implemented `getUsers` method in `userService` to fetch users with optional email filter. - Defined `GetUsersResponse` type in `api.ts` for user data structure. - Created `UserDetailPage` component for displaying individual user details and managing roles. - Developed `UserManagementPage` component for listing users with search and pagination, including role update functionality. - Added centralized validation limits in `validation-limits.ts` for consistent input length management across the application. --- .../creation/individual/register/page.tsx | 10 + .../user/(auth)/forgotPwd/page.tsx | 2 + .../user/(auth)/forgotPwd/reset/page.tsx | 3 + .../(individual)/user/(auth)/login/page.tsx | 3 + .../org/[org_slug]/(auth)/login/page.tsx | 3 + .../(pages)/(layout1)/members/page.tsx | 2 + .../(pages)/(layout1)/roles/page.tsx | 6 + .../(dashboard)/request-organization/page.tsx | 14 + .../(dashboard)/system/email-configs/page.tsx | 3 + .../system/feedback/[feedbackId]/page.tsx | 6 + .../system/feedback/webhooks/page.tsx | 8 + .../(dashboard)/system/org-requests/page.tsx | 2 + .../components/CreateRoleDialog.tsx | 6 + .../app/(dashboard)/system/security/page.tsx | 12 + .../system/surveys/components/SurveyForm.tsx | 12 + .../system/team-members/[memberId]/page.tsx | 2 + .../(dashboard)/system/team-members/page.tsx | 19 +- .../system/user-statistics/[id]/page.tsx | 559 +------------ .../system/user-statistics/page.tsx | 761 ++++++++---------- .../(dashboard)/system/users/[id]/page.tsx | 549 +++++++++++++ .../src/app/(dashboard)/system/users/page.tsx | 596 ++++++++++++++ .../components/VisualizerChartCard.tsx | 15 + .../feedback/components/FeedbackLauncher.tsx | 2 + .../location-insights/more-insights.tsx | 2 + .../components/GroupDetailsSettings.tsx | 12 + .../organization/components/ThemeSettings.tsx | 2 + .../themes/components/ThemeManager.tsx | 2 + .../user-profile/components/ProfileForm.tsx | 10 + .../user-profile/components/SecurityTab.tsx | 4 + .../components/BulkRoleAssignmentDialog.tsx | 2 + .../auth/SetPasswordPromptDialog.tsx | 3 + .../shared/components/sidebar/config/index.ts | 16 +- .../src/shared/components/ui/search-field.tsx | 13 +- .../components/ui/server-side-table.tsx | 2 +- src/platform/src/shared/hooks/useAdmin.ts | 13 + .../src/shared/lib/validation-limits.ts | 76 ++ src/platform/src/shared/lib/validators.ts | 149 +++- .../src/shared/services/userService.ts | 17 + src/platform/src/shared/types/api.ts | 6 + 39 files changed, 1927 insertions(+), 997 deletions(-) create mode 100644 src/platform/src/app/(dashboard)/system/users/[id]/page.tsx create mode 100644 src/platform/src/app/(dashboard)/system/users/page.tsx create mode 100644 src/platform/src/shared/lib/validation-limits.ts diff --git a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/creation/individual/register/page.tsx b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/creation/individual/register/page.tsx index 71536424a0..ae03b34da8 100644 --- a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/creation/individual/register/page.tsx +++ b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/creation/individual/register/page.tsx @@ -8,6 +8,12 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from '@/shared/components/ui'; import { registerSchema, type RegisterFormData } from '@/shared/lib/validators'; +import { + FIRST_NAME_MAX, + LAST_NAME_MAX, + EMAIL_MAX, + PASSWORD_MAX, +} from '@/shared/lib/validation-limits'; import { useRegister } from '@/shared/hooks/useAuth'; import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; import { useRouter } from 'next/navigation'; @@ -68,6 +74,7 @@ export default function RegisterPage() { label="First name" placeholder="Enter your first name" error={errors.firstName?.message} + maxLength={FIRST_NAME_MAX} {...register('firstName')} /> @@ -77,6 +84,7 @@ export default function RegisterPage() { label="Last name" placeholder="Enter your last name" error={errors.lastName?.message} + maxLength={LAST_NAME_MAX} {...register('lastName')} /> @@ -88,6 +96,7 @@ export default function RegisterPage() { placeholder="Enter your email" error={errors.email?.message} containerClassName="mb-4" + maxLength={EMAIL_MAX} {...register('email')} /> @@ -98,6 +107,7 @@ export default function RegisterPage() { placeholder="Create password" error={errors.password?.message} showPasswordToggle + maxLength={PASSWORD_MAX} {...register('password')} />{' '}

diff --git a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/forgotPwd/page.tsx b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/forgotPwd/page.tsx index ea39d3bf44..4777e30376 100644 --- a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/forgotPwd/page.tsx +++ b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/forgotPwd/page.tsx @@ -10,6 +10,7 @@ import { forgotPwdSchema, type ForgotPwdFormData, } from '@/shared/lib/validators'; +import { EMAIL_MAX } from '@/shared/lib/validation-limits'; import { useForgotPassword } from '@/shared/hooks/useAuth'; import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; import { useState } from 'react'; @@ -95,6 +96,7 @@ export default function ForgotPwdPage() { placeholder="e.g. greta.nagawa@gmail.com" error={errors.email?.message} containerClassName="mb-4" + maxLength={EMAIL_MAX} {...register('email')} /> diff --git a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/forgotPwd/reset/page.tsx b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/forgotPwd/reset/page.tsx index 534e738986..ecc5570397 100644 --- a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/forgotPwd/reset/page.tsx +++ b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/forgotPwd/reset/page.tsx @@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from '@/shared/components/ui'; import { resetPwdSchema, type ResetPwdFormData } from '@/shared/lib/validators'; +import { PASSWORD_MAX } from '@/shared/lib/validation-limits'; import { useResetPassword } from '@/shared/hooks/useAuth'; import { useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; @@ -165,6 +166,7 @@ export default function ResetPwdPage() { error={errors.password?.message} containerClassName="mb-4" showPasswordToggle + maxLength={PASSWORD_MAX} {...register('password')} /> @@ -175,6 +177,7 @@ export default function ResetPwdPage() { error={errors.confirmPassword?.message} containerClassName="mb-4" showPasswordToggle + maxLength={PASSWORD_MAX} {...register('confirmPassword')} /> diff --git a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx index dd77f7f9c7..7dd96dc1ca 100644 --- a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx +++ b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx @@ -12,6 +12,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from '@/shared/components/ui'; import { loginSchema, type LoginFormData } from '@/shared/lib/validators'; +import { EMAIL_MAX, PASSWORD_MAX } from '@/shared/lib/validation-limits'; import { normalizeCallbackUrl, redirectWithReload, @@ -184,6 +185,7 @@ export default function LoginPage() { type="email" placeholder="user@example.com" error={errors.email?.message} + maxLength={EMAIL_MAX} {...register('email')} /> @@ -220,6 +222,7 @@ export default function LoginPage() { error={errors.password?.message} containerClassName="mb-0" showPasswordToggle + maxLength={PASSWORD_MAX} {...register('password')} /> diff --git a/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(auth)/login/page.tsx b/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(auth)/login/page.tsx index a061fc5551..78e0376fdd 100644 --- a/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(auth)/login/page.tsx +++ b/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(auth)/login/page.tsx @@ -13,6 +13,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from '@/shared/components/ui'; import { loginSchema, type LoginFormData } from '@/shared/lib/validators'; +import { EMAIL_MAX, PASSWORD_MAX } from '@/shared/lib/validation-limits'; import { normalizeCallbackUrl, redirectWithReload, @@ -177,6 +178,7 @@ export default function OrgLoginPage() { {...register('email')} error={errors.email?.message} placeholder="Enter your email" + maxLength={EMAIL_MAX} /> - - - - - ) : ( -

-
- -
- - - Refresh - - } - /> - -
- -
-
-

User Information

-

- Basic profile and account information -

-
-
- - {user.isActive ? 'Active' : 'Inactive'} - - - {user.verified ? 'Verified' : 'Unverified'} - -
-
- -
- - - - - - - - -
- - {user.description && ( -
- -

{user.description}

-
- )} -
- - -
-
-

Role Assignment

-

- Update the selected role for this user -

-
- -
- -
-
-

- Role groups loaded -

-

- {availableRoles.length} -

-
- - {rolesError ? ( - - ) : ( - - )} - -
-

Permissions Overview

-

- {currentPermissions.length} direct permissions on this user -

-
- {currentPermissions.slice(0, 6).map(permission => ( - - {permission.permission} - - ))} - {currentPermissions.length > 6 && ( - - +{currentPermissions.length - 6} more - - )} -
-
-
-
- - -
-
-

Current Access

-

- Roles inherited from networks and groups -

-
-
- - {currentRoleEntries.length} role assignments -
-
- -
-
-

- Networks -

- {user.networks?.length ? ( - user.networks.map(network => ( -
-
-

{network.net_name}

-

- {network.role?.role_name || 'No role assigned'} -

-
-
- - {network.userType} -
-
- )) - ) : ( -

- No network roles assigned. -

- )} -
- -
-

- Groups -

-
- {user.groups?.length ? ( - user.groups.map(group => ( -
-

{group.grp_title}

-

- {group.organization_slug} -

-

- {group.role?.role_name || 'No role assigned'} -

-
- )) - ) : ( -

- No group roles assigned. -

- )} -
-
-
- -
-

- Permissions -

-
- {currentPermissions.map(permission => ( - - {permission.permission} - - ))} - {!currentPermissions.length && ( -

- No direct permissions returned for this user. -

- )} -
-
-
-
- - setIsRoleDialogOpen(false)} - title="Update User Role" - primaryAction={{ - label: 'Update Role', - onClick: handleUpdateRole, - disabled: updateUserRole.isMutating || !selectedRoleId, - loading: updateUserRole.isMutating, - }} - secondaryAction={{ - label: 'Cancel', - onClick: () => setIsRoleDialogOpen(false), - disabled: updateUserRole.isMutating, - variant: 'outlined', - }} - > -
-
- - setRoleSearch(e.target.value)} - placeholder="Search roles or groups..." - className="w-full mb-2 px-3 py-2 border rounded-md text-sm" - /> - - {rolesLoading && ( -

- Loading all selectable roles... -

- )} -
- - {selectedRole && ( -
-
-

Selected Role

-

- {selectedRole.role_name} -

-

- {selectedRole.group?.grp_title || 'No group'} -

-
- -
-

Permissions

-
- {(selectedRole.role_permissions || []).map(permission => ( - - {permission.permission} - - ))} -
-
-
- )} -
-
-
- )} - - ); -}; - -const DetailItem: React.FC<{ label: string; value: string }> = ({ - label, - value, -}) => { - return ( -
- -

{value}

-
- ); -}; - -export default UserStatisticsDetailsPage; +import { redirect } from 'next/navigation'; + +interface UserDetailRedirectPageProps { + params: Promise<{ id: string }>; +} + +export default async function UserDetailRedirectPage({ + params, +}: UserDetailRedirectPageProps) { + const { id } = await params; + redirect(`/system/users/${id}`); +} diff --git a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx index bd69ead836..a64a3db1d6 100644 --- a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx +++ b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx @@ -1,321 +1,141 @@ 'use client'; -import React, { useState, useMemo, useEffect, useCallback } from 'react'; -import { useRouter } from 'next/navigation'; -import { ServerSideTable } from '@/shared/components/ui/server-side-table'; -import { Button, PageHeading } from '@/shared/components/ui'; -import { useUserStatistics } from '@/shared/hooks/useAdmin'; -import { LoadingState } from '@/shared/components/ui/loading-state'; +import React, { useMemo, useCallback } from 'react'; +import Link from 'next/link'; +import { + Button, + Card, + LoadingState, + PageHeading, +} from '@/shared/components/ui'; import { ErrorBanner } from '@/shared/components/ui/banner'; import { PermissionGuard } from '@/shared/components'; import { AccessDenied } from '@/shared/components/AccessDenied'; import { isForbiddenError } from '@/shared/utils/errorMessages'; -import { AqUsers01, AqUsersCheck, AqKey01, AqEye } from '@airqo/icons-react'; -import { Card } from '@/shared/components/ui/card'; -import jsPDF from 'jspdf'; -import autoTable from 'jspdf-autotable'; -import type { UserStatisticsUser } from '@/shared/types/api'; import { toast } from '@/shared/components/ui/toast'; import { refreshWithToast } from '@/shared/utils/refreshWithToast'; +import { useUsers } from '@/shared/hooks/useAdmin'; +import { formatWithPattern } from '@/shared/utils/dateUtils'; +import { ChartContainer } from '@/shared/components/charts'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/shared/components/ui'; -import BulkRoleAssignmentDialog from '@/shared/components/BulkRoleAssignmentDialog'; + AqUsers01, + AqUsersCheck, + AqKey01, + AqMail01, + AqRefreshCw05, + AqArrowRight, + AqPresentationChart02, +} from '@airqo/icons-react'; +import { + PieChart, + Pie, + Cell, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + LineChart, + Line, +} from 'recharts'; + +const COLORS = ['#1649e5', '#059669', '#d97706', '#dc2626', '#7c3aed', '#0891b2']; + +interface ChartDataPoint { + name: string; + value: number; + [key: string]: string | number; +} const UserStatisticsPage: React.FC = () => { - const router = useRouter(); - const { data, isLoading, error, mutate } = useUserStatistics(); - - // State for tabs - const [activeTab, setActiveTab] = useState<'all' | 'active' | 'api'>('all'); - - // Pagination and search states - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [search, setSearch] = useState(''); - const [selectedUsers, setSelectedUsers] = useState([]); - const [showBulkRoleDialog, setShowBulkRoleDialog] = useState(false); - - const stats = data?.users_stats; - - // Get current data based on active tab - const currentData = useMemo(() => { - if (!stats) return []; - switch (activeTab) { - case 'active': - return stats.active_users?.details ?? []; - case 'api': - return stats.api_users?.details ?? []; - default: - return stats.users?.details ?? []; - } - }, [stats, activeTab]); - - // Filter and paginate data - const filteredData = useMemo(() => { - return currentData - .filter((user: UserStatisticsUser) => - `${user.firstName} ${user.lastName} ${user.email} ${user.userName}` - .toLowerCase() - .includes(search.toLowerCase()) - ) - .map((user: UserStatisticsUser) => ({ - ...user, - id: user._id, - })); - }, [currentData, search]); - - const paginatedData = useMemo(() => { - const start = (page - 1) * pageSize; - const end = start + pageSize; - return filteredData.slice(start, end); - }, [filteredData, page, pageSize]); + const { data, isLoading, error, mutate } = useUsers(); - const totalPages = Math.ceil(filteredData.length / pageSize); + const users = useMemo(() => data?.users ?? [], [data]); - // Reset page to 1 when search changes - useEffect(() => { - setPage(1); - setSelectedUsers([]); - }, [search]); + const stats = useMemo(() => { + const total = users.length; + const active = users.filter(u => u.isActive).length; + const verified = users.filter(u => u.verified).length; + const apiUsers = users.filter(u => (u.clients?.length ?? 0) > 0).length; + return { total, active, verified, apiUsers }; + }, [users]); - const handleViewDetails = useCallback( - (userId: string) => { - router.push(`/system/user-statistics/${userId}`); - }, - [router] + const statusData: ChartDataPoint[] = useMemo( + () => [ + { name: 'Active', value: stats.active }, + { name: 'Inactive', value: stats.total - stats.active }, + ], + [stats] ); - // Table columns - const columns = useMemo( + const verificationData: ChartDataPoint[] = useMemo( () => [ - { - key: 'user', - label: 'User', - render: (value: unknown, user: UserStatisticsUser) => ( -
-
- - {user.firstName?.[0] || '?'} - {user.lastName?.[0] || ''} - -
-
-
- {user.firstName} {user.lastName} -
-
{user.email}
-
-
- ), - }, - { - key: 'userName', - label: 'Username', - render: (value: unknown, user: UserStatisticsUser) => ( -
{user.userName || '--'}
- ), - }, - { - key: 'id', - label: 'User ID', - render: (value: unknown, user: UserStatisticsUser) => ( -
- {user._id} -
- ), - }, - { - key: 'actions', - label: 'Actions', - render: (value: unknown, user: UserStatisticsUser) => ( - - - - - - handleViewDetails(user._id)}> - - - View Details - - - - - ), - }, + { name: 'Verified', value: stats.verified }, + { name: 'Unverified', value: stats.total - stats.verified }, ], - [handleViewDetails] + [stats] ); - // Stats cards data - const statsCards = [ - { - id: 'all', - title: 'Total Users', - count: stats?.users.number || 0, - icon: AqUsers01, - color: 'bg-blue-100 text-blue-800', - }, - { - id: 'active', - title: 'Active Users', - count: stats?.active_users.number || 0, - icon: AqUsersCheck, - color: 'bg-green-100 text-green-800', - }, - { - id: 'api', - title: 'API Users', - count: stats?.api_users.number || 0, - icon: AqKey01, - color: 'bg-purple-100 text-purple-800', - }, - ]; - - const exportToCSV = () => { - const headers = ['First Name', 'Last Name', 'Email', 'Username', 'User ID']; - - // Helper to safely extract string fields from possibly inconsistent shapes - const getField = (obj: unknown, ...keys: string[]): string => { - if (!obj || typeof obj !== 'object') return ''; - const rec = obj as Record; - for (const k of keys) { - const v = rec[k as string]; - if (v == null) continue; - if (typeof v === 'string') return v; - if (typeof v === 'number') return String(v); - } - return ''; - }; - - const esc = (s: string) => { - if (!s) return ''; - // Neutralize potential spreadsheet formulas by prefixing a single quote - // when the value begins with =, +, -, or @ (after leading whitespace), - // or when it literally starts with a tab or carriage return. - const firstNonWS = s.trimStart().charAt(0); - const startsWithFormulaChar = - firstNonWS === '=' || - firstNonWS === '+' || - firstNonWS === '-' || - firstNonWS === '@'; - const startsWithTabOrCR = s.charAt(0) === '\t' || s.charAt(0) === '\r'; - const needsNeutralize = startsWithFormulaChar || startsWithTabOrCR; - const prefixed = needsNeutralize ? `'${s}` : s; - return prefixed.replace(/"/g, '""'); - }; - - const csvData = currentData.map(user => { - const firstName = getField(user, 'firstName', 'firstname'); - const lastName = getField(user, 'lastName', 'lastname'); - const email = getField(user, 'email', 'userEmail'); - const userName = getField(user, 'userName', 'username'); - const id = getField(user, '_id', 'id'); - - return [ - esc(firstName), - esc(lastName), - esc(email), - esc(userName), - esc(id), - ]; - }); - - const rows = [headers, ...csvData]; - const csvContent = rows - .map(row => row.map(field => `"${field}"`).join(',')) - .join('\n'); - // Add BOM for better Excel compatibility - const blob = new Blob(['\uFEFF' + csvContent], { - type: 'text/csv;charset=utf-8;', - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `user-statistics-${activeTab}-${new Date().toISOString().split('T')[0]}.csv`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - const exportToPDF = () => { - const doc = new jsPDF({ unit: 'pt', format: 'a4' }); - - // Header - doc.setFontSize(18); - doc.setFont('helvetica', 'bold'); - doc.text('AirQo User Statistics Report', 40, 50); - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text( - `Category: ${activeTab === 'all' ? 'All Users' : activeTab === 'active' ? 'Active Users' : 'API Users'}`, - 40, - 70 - ); - doc.text(`Total Records: ${currentData.length}`, 40, 85); - doc.text(`Generated on: ${new Date().toLocaleString()}`, 40, 100); - - const getField = (obj: unknown, ...keys: string[]): string => { - if (!obj || typeof obj !== 'object') return ''; - const rec = obj as Record; - for (const k of keys) { - const v = rec[k as string]; - if (v == null) continue; - if (typeof v === 'string') return v; - if (typeof v === 'number') return String(v); - } - return ''; - }; - - const tableData = currentData.map(user => { - const firstName = getField(user, 'firstName', 'firstname'); - const lastName = getField(user, 'lastName', 'lastname'); - const email = getField(user, 'email', 'userEmail'); - const userName = getField(user, 'userName', 'username'); - const id = getField(user, '_id', 'id'); - return [firstName, lastName, email, userName, id]; - }); - - autoTable(doc, { - head: [['First Name', 'Last Name', 'Email', 'Username', 'User ID']], - body: tableData, - startY: 120, - styles: { fontSize: 9 }, - headStyles: { fillColor: [22, 78, 99] }, - alternateRowStyles: { fillColor: [245, 245, 245] }, - margin: { left: 40, right: 40 }, - // Note: page footers are rendered after autoTable completes to ensure - // the correct total page count is available. - }); - - // Add "Page X of Y" footer on each page after table generation - const pageCount = doc.getNumberOfPages(); - for (let i = 1; i <= pageCount; i++) { - doc.setPage(i); - doc.setFontSize(9); - doc.text( - `Page ${i} of ${pageCount}`, - 40, - doc.internal.pageSize.height - 30 - ); - } - - doc.save( - `user-statistics-${activeTab}-${new Date().toISOString().split('T')[0]}.pdf` - ); - }; + const organizationData: ChartDataPoint[] = useMemo(() => { + const counts = users.reduce>((acc, user) => { + const org = user.organization?.trim() || 'Unknown'; + acc[org] = (acc[org] ?? 0) + 1; + return acc; + }, {}); + return Object.entries(counts) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value) + .slice(0, 8); + }, [users]); + + const loginRangesData: ChartDataPoint[] = useMemo(() => { + const ranges = [ + { name: '0', min: 0, max: 0 }, + { name: '1-5', min: 1, max: 5 }, + { name: '6-10', min: 6, max: 10 }, + { name: '11-20', min: 11, max: 20 }, + { name: '21+', min: 21, max: Infinity }, + ]; + return ranges.map(range => ({ + name: range.name, + value: users.filter( + u => + (u.loginCount ?? 0) >= range.min && + (range.max === Infinity || (u.loginCount ?? 0) <= range.max) + ).length, + })); + }, [users]); + + const signupsOverTimeData: ChartDataPoint[] = useMemo(() => { + const counts = users.reduce>((acc, user) => { + const date = user.createdAt + ? formatWithPattern(user.createdAt, 'yyyy-MM') + : 'Unknown'; + acc[date] = (acc[date] ?? 0) + 1; + return acc; + }, {}); + return Object.entries(counts) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(-12); + }, [users]); + + const topGroupsData: ChartDataPoint[] = useMemo(() => { + const counts = users.reduce>((acc, user) => { + (user.groups ?? []).forEach(group => { + const title = group.grp_title?.trim() || group.organization_slug || 'Unknown'; + acc[title] = (acc[title] ?? 0) + 1; + }); + return acc; + }, {}); + return Object.entries(counts) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value) + .slice(0, 8); + }, [users]); const handleRefresh = useCallback(async () => { try { @@ -323,10 +143,10 @@ const UserStatisticsPage: React.FC = () => { () => mutate(), 'User statistics refreshed successfully' ); - } catch (error) { + } catch (err) { toast.error( - error instanceof Error - ? error.message + err instanceof Error + ? err.message : 'Unable to refresh user statistics' ); } @@ -351,17 +171,14 @@ const UserStatisticsPage: React.FC = () => { ); } return ( -
+
- +
); } @@ -370,121 +187,245 @@ const UserStatisticsPage: React.FC = () => {
+ + + + +
+ } /> {/* Statistics Cards */} -
- {statsCards.map(card => { - const Icon = card.icon; - const isActive = activeTab === card.id; - return ( - { - setActiveTab(card.id as 'all' | 'active' | 'api'); - setPage(1); - setSearch(''); - setSelectedUsers([]); - }} - className={`cursor-pointer p-6 transition-all ${ - isActive ? 'border-primary bg-primary/5' : 'hover:shadow-md' - }`} - > -
-
-

- {card.title} -

-

{card.count}

-
-
- -
-
-
- ); - })} +
+ +
+
+

Total Users

+

{stats.total}

+
+
+ +
+
+
+ +
+
+

Active Users

+

{stats.active}

+
+
+ +
+
+
+ +
+
+

Verified Users

+

{stats.verified}

+
+
+ +
+
+
+ +
+
+

API Users

+

{stats.apiUsers}

+
+
+ +
+
+
-
- -
- {selectedUsers.length > 0 && ( - + {stats.total > 0 ? ( + + + + + `${name}: ${value}`} + > + {statusData.map((_, index) => ( + + ))} + + + + ) : ( + )} - - -
-
- { - setPageSize(newSize); - setPage(1); - }} - searchTerm={search} - onSearchChange={value => { - setSearch(value); - setPage(1); - }} - loading={isLoading} - multiSelect - selectedItems={selectedUsers} - onSelectedItemsChange={ids => - setSelectedUsers(ids.map(id => String(id))) - } - /> + - setShowBulkRoleDialog(false)} - userIds={selectedUsers} - userCount={selectedUsers.length} - onSuccess={() => { - setSelectedUsers([]); - mutate(); - }} - /> + + {stats.total > 0 ? ( + + + + + `${name}: ${value}`} + > + {verificationData.map((_, index) => ( + + ))} + + + + ) : ( + + )} + + + + {organizationData.length > 0 ? ( + + + + + + + + + + ) : ( + + )} + + + + {loginRangesData.some(d => d.value > 0) ? ( + + + + + + + + + + ) : ( + + )} + + + + {signupsOverTimeData.length > 0 ? ( + + + + + + + + + + ) : ( + + )} + + + + {topGroupsData.length > 0 ? ( + + + + + + + + + + ) : ( + + )} + +
); }; -// Wrap with permission guard +const NoDataState: React.FC = () => ( +
+
+ +

No data available

+
+
+); + const ProtectedUserStatisticsPage: React.FC = () => { return ( { + const params = useParams(); + const router = useRouter(); + const userId = params.id as string; + + const { + data: userResponse, + isLoading: userLoading, + error: userError, + mutate: mutateUser, + } = useUserDetails(userId); + const { + data: rolesResponse, + isLoading: rolesLoading, + error: rolesError, + mutate: mutateRoles, + } = useRolesSummary(); + const updateUserRole = useUpdateUserRole(); + + const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false); + const [selectedRoleId, setSelectedRoleId] = useState(''); + const [roleSearch, setRoleSearch] = useState(''); + + const user = userResponse?.users?.[0]; + const availableRoles = useMemo( + () => rolesResponse?.roles || [], + [rolesResponse?.roles] + ); + + const filteredRoles = useMemo(() => { + const q = roleSearch.trim().toLowerCase(); + if (!q) return availableRoles; + return availableRoles.filter(r => { + const name = (r.role_name || '').toLowerCase(); + const group = (r.group?.grp_title || '').toLowerCase(); + return name.includes(q) || group.includes(q); + }); + }, [availableRoles, roleSearch]); + + const primaryRoleId = useMemo(() => { + return ( + user?.networks?.[0]?.role?._id || + user?.groups?.[0]?.role?._id || + availableRoles[0]?._id || + '' + ); + }, [availableRoles, user]); + + const currentRoleEntries = useMemo(() => { + const entries: Array<{ + scope: 'Network' | 'Group'; + name: string; + roleName: string; + rolePermissions: { _id: string; permission: string }[]; + }> = []; + + user?.networks?.forEach(network => { + entries.push({ + scope: 'Network', + name: network.net_name, + roleName: network.role?.role_name || 'No role assigned', + rolePermissions: network.role?.role_permissions || [], + }); + }); + + user?.groups?.forEach(group => { + entries.push({ + scope: 'Group', + name: group.grp_title, + roleName: group.role?.role_name || 'No role assigned', + rolePermissions: group.role?.role_permissions || [], + }); + }); + + return entries; + }, [user]); + + const currentPermissions = user?.permissions || []; + + const selectedRole = useMemo( + () => availableRoles.find(role => role._id === selectedRoleId), + [availableRoles, selectedRoleId] + ); + + const handleBack = useCallback(() => { + router.push('/system/users'); + }, [router]); + + const handleRefresh = useCallback(async () => { + try { + await refreshWithToast( + () => Promise.all([mutateUser(), mutateRoles()]), + 'User details refreshed successfully' + ); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } + }, [mutateRoles, mutateUser]); + + const openRoleDialog = useCallback(() => { + setSelectedRoleId(primaryRoleId); + setIsRoleDialogOpen(true); + }, [primaryRoleId]); + + const handleUpdateRole = async () => { + if (!selectedRoleId) { + toast.error('Please select a role'); + return; + } + + try { + await updateUserRole.trigger({ + userId, + roleId: selectedRoleId, + }); + + toast.success('User role updated successfully'); + setIsRoleDialogOpen(false); + await Promise.all([mutateUser(), mutateRoles()]); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } + }; + + return ( + + {userLoading ? ( + + ) : isForbiddenError(userError) ? ( + + ) : userError || !user ? ( +
+ + + + +
+ ) : ( +
+
+ +
+ + + Refresh + + } + /> + +
+ +
+
+

User Information

+

+ Basic profile and account information +

+
+
+ + {user.isActive ? 'Active' : 'Inactive'} + + + {user.verified ? 'Verified' : 'Unverified'} + +
+
+ +
+ + + + + + + + +
+ + {user.description && ( +
+ +

{user.description}

+
+ )} +
+ + +
+
+

Role Assignment

+

+ Update the selected role for this user +

+
+ +
+ +
+
+

+ Role groups loaded +

+

+ {availableRoles.length} +

+
+ + {rolesError ? ( + + ) : ( + + )} + +
+

Permissions Overview

+

+ {currentPermissions.length} direct permissions on this user +

+
+ {currentPermissions.slice(0, 6).map(permission => ( + + {permission.permission} + + ))} + {currentPermissions.length > 6 && ( + + +{currentPermissions.length - 6} more + + )} +
+
+
+
+ + +
+
+

Current Access

+

+ Roles inherited from networks and groups +

+
+
+ + {currentRoleEntries.length} role assignments +
+
+ +
+
+

+ Networks +

+ {user.networks?.length ? ( + user.networks.map(network => ( +
+
+

{network.net_name}

+

+ {network.role?.role_name || 'No role assigned'} +

+
+
+ + {network.userType} +
+
+ )) + ) : ( +

+ No network roles assigned. +

+ )} +
+ +
+

+ Groups +

+
+ {user.groups?.length ? ( + user.groups.map(group => ( +
+

{group.grp_title}

+

+ {group.organization_slug} +

+

+ {group.role?.role_name || 'No role assigned'} +

+
+ )) + ) : ( +

+ No group roles assigned. +

+ )} +
+
+
+ +
+

+ Permissions +

+
+ {currentPermissions.map(permission => ( + + {permission.permission} + + ))} + {!currentPermissions.length && ( +

+ No direct permissions returned for this user. +

+ )} +
+
+
+
+ + setIsRoleDialogOpen(false)} + title="Update User Role" + primaryAction={{ + label: 'Update Role', + onClick: handleUpdateRole, + disabled: updateUserRole.isMutating || !selectedRoleId, + loading: updateUserRole.isMutating, + }} + secondaryAction={{ + label: 'Cancel', + onClick: () => setIsRoleDialogOpen(false), + disabled: updateUserRole.isMutating, + variant: 'outlined', + }} + > +
+
+ + setRoleSearch(e.target.value)} + placeholder="Search roles or groups..." + maxLength={SEARCH_TERM_MAX} + className="w-full mb-2 px-3 py-2 border rounded-md text-sm" + /> + + {rolesLoading && ( +

+ Loading all selectable roles... +

+ )} +
+ + {selectedRole && ( +
+
+

Selected Role

+

+ {selectedRole.role_name} +

+

+ {selectedRole.group?.grp_title || 'No group'} +

+
+ +
+

Permissions

+
+ {(selectedRole.role_permissions || []).map(permission => ( + + {permission.permission} + + ))} +
+
+
+ )} +
+
+
+ )} +
+ ); +}; + +const DetailItem: React.FC<{ label: string; value: string }> = ({ + label, + value, +}) => { + return ( +
+ +

{value}

+
+ ); +}; + +export default UserDetailPage; diff --git a/src/platform/src/app/(dashboard)/system/users/page.tsx b/src/platform/src/app/(dashboard)/system/users/page.tsx new file mode 100644 index 0000000000..0c236e008c --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/users/page.tsx @@ -0,0 +1,596 @@ +'use client'; + +import React, { useMemo, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Button, + Card, + Input, + LoadingState, + PageHeading, +} from '@/shared/components/ui'; +import { ServerSideTable } from '@/shared/components/ui/server-side-table'; +import { ErrorBanner } from '@/shared/components/ui/banner'; +import { PermissionGuard } from '@/shared/components'; +import { AccessDenied } from '@/shared/components/AccessDenied'; +import { + isForbiddenError, + getUserFriendlyErrorMessage, +} from '@/shared/utils/errorMessages'; +import { toast } from '@/shared/components/ui/toast'; +import { refreshWithToast } from '@/shared/utils/refreshWithToast'; +import { useUsers, useUpdateUserRole } from '@/shared/hooks/useAdmin'; +import { useRolesSummary } from '@/shared/hooks/useAdmin'; +import { formatWithPattern } from '@/shared/utils/dateUtils'; +import type { User } from '@/shared/types/api'; +import { + AqEye, + AqRefreshCw05, + AqDownload01, + AqShield02, + AqUsers01, + AqUsersCheck, + AqMail01, + AqKey01, +} from '@airqo/icons-react'; +import jsPDF from 'jspdf'; +import autoTable from 'jspdf-autotable'; +import { Dialog, Select } from '@/shared/components/ui'; +import { SEARCH_TERM_MAX } from '@/shared/lib/validation-limits'; + +interface UsersTableRow extends User { + id: string; +} + +const UserManagementPage: React.FC = () => { + const router = useRouter(); + const { data, isLoading, error, mutate } = useUsers(); + const { data: rolesResponse, isLoading: rolesLoading } = useRolesSummary(); + const updateUserRole = useUpdateUserRole(); + + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [search, setSearch] = useState(''); + + const [roleDialogUser, setRoleDialogUser] = useState(null); + const [selectedRoleId, setSelectedRoleId] = useState(''); + const [roleSearch, setRoleSearch] = useState(''); + + const users = useMemo(() => data?.users ?? [], [data]); + const availableRoles = useMemo( + () => rolesResponse?.roles ?? [], + [rolesResponse] + ); + + const filteredUsers = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return users; + return users.filter(user => { + const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim(); + const haystack = [ + fullName, + user.email, + user.userName, + user._id, + user.organization, + user.country, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(q); + }); + }, [users, search]); + + const paginatedUsers = useMemo(() => { + const start = (page - 1) * pageSize; + const end = start + pageSize; + return filteredUsers.slice(start, end).map(user => ({ + ...user, + id: user._id, + })); + }, [filteredUsers, page, pageSize]); + + const totalPages = Math.ceil(filteredUsers.length / pageSize); + + const filteredRoles = useMemo(() => { + const q = roleSearch.trim().toLowerCase(); + if (!q) return availableRoles; + return availableRoles.filter(role => { + const name = (role.role_name || '').toLowerCase(); + const group = (role.group?.grp_title || '').toLowerCase(); + return name.includes(q) || group.includes(q); + }); + }, [availableRoles, roleSearch]); + + const stats = useMemo(() => { + const total = users.length; + const active = users.filter(u => u.isActive).length; + const verified = users.filter(u => u.verified).length; + const apiUsers = users.filter(u => (u.clients?.length ?? 0) > 0).length; + return { total, active, verified, apiUsers }; + }, [users]); + + const handleRefresh = useCallback(async () => { + try { + await refreshWithToast(() => mutate(), 'User list refreshed successfully'); + } catch (err) { + toast.error(getUserFriendlyErrorMessage(err)); + } + }, [mutate]); + + const openRoleDialog = useCallback( + (user: User) => { + const primaryRoleId = + user.groups?.[0]?.role?._id || + user.networks?.[0]?.role?._id || + availableRoles[0]?._id || + ''; + setRoleDialogUser(user); + setSelectedRoleId(primaryRoleId); + setRoleSearch(''); + }, + [availableRoles] + ); + + const handleUpdateRole = async () => { + if (!roleDialogUser || !selectedRoleId) { + toast.error('Please select a role'); + return; + } + try { + await updateUserRole.trigger({ + userId: roleDialogUser._id, + roleId: selectedRoleId, + }); + toast.success('User role updated successfully'); + setRoleDialogUser(null); + await mutate(); + } catch (err) { + toast.error(getUserFriendlyErrorMessage(err)); + } + }; + + const selectedRole = useMemo( + () => availableRoles.find(role => role._id === selectedRoleId), + [availableRoles, selectedRoleId] + ); + + const exportToCSV = () => { + const headers = [ + 'First Name', + 'Last Name', + 'Email', + 'Username', + 'User ID', + 'Status', + 'Verified', + 'Login Count', + 'Last Login', + 'Organization', + 'Country', + 'Job Title', + 'Groups', + ]; + + const escape = (s: string) => { + if (!s) return ''; + const first = s.trimStart().charAt(0); + const neutralize = ['=', '+', '-', '@'].includes(first); + const text = neutralize ? `'${s}` : s; + return text.replace(/"/g, '""'); + }; + + const rows = filteredUsers.map(user => [ + escape(user.firstName || ''), + escape(user.lastName || ''), + escape(user.email || ''), + escape(user.userName || ''), + escape(user._id || ''), + user.isActive ? 'Active' : 'Inactive', + user.verified ? 'Yes' : 'No', + String(user.loginCount ?? 0), + user.lastLogin + ? formatWithPattern(user.lastLogin, 'yyyy-MM-dd HH:mm') + : '', + escape(user.organization || ''), + escape(user.country || ''), + escape(user.jobTitle || ''), + escape( + (user.groups ?? []).map(g => g.grp_title || g.organization_slug).join('; ') + ), + ]); + + const csvContent = [headers, ...rows] + .map(row => row.map(field => `"${field}"`).join(',')) + .join('\n'); + + const blob = new Blob(['\uFEFF' + csvContent], { + type: 'text/csv;charset=utf-8;', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `users-export-${new Date().toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const exportToPDF = () => { + const doc = new jsPDF({ unit: 'pt', format: 'a4' }); + doc.setFontSize(18); + doc.setFont('helvetica', 'bold'); + doc.text('AirQo User Management Report', 40, 50); + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text(`Total Records: ${filteredUsers.length}`, 40, 70); + doc.text(`Generated on: ${new Date().toLocaleString()}`, 40, 85); + + const tableData = filteredUsers.map(user => [ + `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim() || '--', + user.email || '--', + user.userName || '--', + user.isActive ? 'Active' : 'Inactive', + user.verified ? 'Yes' : 'No', + String(user.loginCount ?? 0), + ]); + + autoTable(doc, { + head: [['Name', 'Email', 'Username', 'Status', 'Verified', 'Logins']], + body: tableData, + startY: 105, + styles: { fontSize: 9 }, + headStyles: { fillColor: [22, 78, 99] }, + alternateRowStyles: { fillColor: [245, 245, 245] }, + margin: { left: 40, right: 40 }, + }); + + const pageCount = doc.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + doc.setFontSize(9); + doc.text( + `Page ${i} of ${pageCount}`, + 40, + doc.internal.pageSize.height - 30 + ); + } + + doc.save(`users-export-${new Date().toISOString().split('T')[0]}.pdf`); + }; + + const columns = useMemo( + () => [ + { + key: 'user', + label: 'User', + render: (_value: unknown, user: UsersTableRow) => ( +
+
+ + {user.firstName?.[0] || '?'} + {user.lastName?.[0] || ''} + +
+
+
+ {user.firstName} {user.lastName} +
+
+ {user.email} +
+
+
+ ), + }, + { + key: 'userName', + label: 'Username', + render: (_value: unknown, user: UsersTableRow) => ( +
{user.userName || '--'}
+ ), + }, + { + key: 'organization', + label: 'Organization', + render: (_value: unknown, user: UsersTableRow) => ( +
{user.organization || '--'}
+ ), + }, + { + key: 'status', + label: 'Status', + render: (_value: unknown, user: UsersTableRow) => ( +
+ + {user.isActive ? 'Active' : 'Inactive'} + + {user.verified ? ( + + Verified + + ) : null} +
+ ), + }, + { + key: 'loginCount', + label: 'Logins', + render: (_value: unknown, user: UsersTableRow) => ( +
{user.loginCount ?? 0}
+ ), + }, + { + key: 'lastLogin', + label: 'Last Login', + render: (_value: unknown, user: UsersTableRow) => ( +
+ {user.lastLogin + ? formatWithPattern(user.lastLogin, 'MMM dd, yyyy') + : 'Never'} +
+ ), + }, + { + key: 'actions', + label: 'Actions', + render: (_value: unknown, user: UsersTableRow) => ( +
+ + +
+ ), + }, + ], + [router, openRoleDialog, rolesLoading, availableRoles.length] + ); + + if (isLoading) { + return ( + + ); + } + + if (error) { + if (isForbiddenError(error)) { + return ( + + ); + } + return ( +
+ + +
+ ); + } + + return ( +
+ + + + +
+ } + /> + + {/* Summary Cards */} +
+ +
+
+

Total Users

+

{stats.total}

+
+
+ +
+
+
+ +
+
+

Active Users

+

{stats.active}

+
+
+ +
+
+
+ +
+
+

Verified Users

+

{stats.verified}

+
+
+ +
+
+
+ +
+
+

API Users

+

{stats.apiUsers}

+
+
+ +
+
+
+
+ + {/* Users Table */} + { + setPageSize(size); + setPage(1); + }} + searchTerm={search} + onSearchChange={value => { + setSearch(value); + setPage(1); + }} + /> + + {/* Role Update Dialog */} + setRoleDialogUser(null)} + title={`Update Role: ${roleDialogUser?.firstName ?? ''} ${ + roleDialogUser?.lastName ?? '' + }`.trim()} + primaryAction={{ + label: 'Update Role', + onClick: handleUpdateRole, + disabled: updateUserRole.isMutating || !selectedRoleId, + loading: updateUserRole.isMutating, + }} + secondaryAction={{ + label: 'Cancel', + onClick: () => setRoleDialogUser(null), + disabled: updateUserRole.isMutating, + variant: 'outlined', + }} + > +
+
+ + setRoleSearch(e.target.value)} + placeholder="Search roles or groups..." + maxLength={SEARCH_TERM_MAX} + containerClassName="mb-2" + /> + +
+ + {selectedRole && ( +
+
+

Selected Role

+

+ {selectedRole.role_name} +

+

+ {selectedRole.group?.grp_title || 'No group'} +

+
+
+

Permissions

+
+ {(selectedRole.role_permissions || []).map(permission => ( + + {permission.permission} + + ))} +
+
+
+ )} +
+
+ + ); +}; + +const ProtectedUserManagementPage: React.FC = () => { + return ( + + + + ); +}; + +export default ProtectedUserManagementPage; diff --git a/src/platform/src/modules/data-visualizer/components/VisualizerChartCard.tsx b/src/platform/src/modules/data-visualizer/components/VisualizerChartCard.tsx index e984051f57..96905a5eab 100644 --- a/src/platform/src/modules/data-visualizer/components/VisualizerChartCard.tsx +++ b/src/platform/src/modules/data-visualizer/components/VisualizerChartCard.tsx @@ -46,6 +46,12 @@ import { toast, } from '@/shared/components/ui'; import { cn } from '@/shared/lib/utils'; +import { + CHART_TITLE_MAX, + CHART_SUBTITLE_MAX, + CHART_AXIS_LABEL_MAX, + CHART_REFERENCE_LABEL_MAX, +} from '@/shared/lib/validation-limits'; import { VisualizerChart } from './VisualizerChart'; import { VisualizerMapChart } from './VisualizerMapChart'; @@ -612,6 +618,7 @@ export const VisualizerChartCard: React.FC = ({ onChange={event => setDraftTitle(event.target.value)} onBlur={commitTitle} onKeyDown={event => handleEditableKeyDown(event, 'title')} + maxLength={CHART_TITLE_MAX} className="w-full rounded-md border border-primary/40 bg-background px-2 py-1 text-lg font-semibold leading-snug text-foreground outline-none ring-2 ring-primary/10" aria-label="Custom chart title" /> @@ -643,6 +650,7 @@ export const VisualizerChartCard: React.FC = ({ onBlur={commitSubtitle} onKeyDown={event => handleEditableKeyDown(event, 'subtitle')} placeholder="Add a custom subtitle" + maxLength={CHART_SUBTITLE_MAX} className="mt-1 w-full rounded-md border border-primary/40 bg-background px-2 py-1 text-sm leading-relaxed text-muted-foreground outline-none ring-2 ring-primary/10" aria-label="Custom chart subtitle" /> @@ -796,6 +804,7 @@ export const VisualizerChartCard: React.FC = ({ updateChart({ title: event.target.value }) } containerClassName="mb-0 md:col-span-2" + maxLength={CHART_TITLE_MAX} /> = ({ updateChart({ subtitle: event.target.value }) } containerClassName="mb-0 md:col-span-2" + maxLength={CHART_SUBTITLE_MAX} /> = ({ updateChart({ subtitle: event.target.value }) } containerClassName="mb-0 md:col-span-2" + maxLength={CHART_SUBTITLE_MAX} /> = ({ }) } containerClassName="mb-0" + maxLength={CHART_AXIS_LABEL_MAX} /> = ({ userId }) => { type="text" placeholder="Enter your last name" error={errors.lastName?.message} + maxLength={LAST_NAME_MAX} required /> @@ -371,6 +379,7 @@ const ProfileForm: React.FC = ({ userId }) => { type="text" placeholder="Enter your job title" error={errors.jobTitle?.message} + maxLength={JOB_TITLE_MAX} /> @@ -404,6 +413,7 @@ const ProfileForm: React.FC = ({ userId }) => { placeholder="Tell us about yourself..." error={errors.description?.message} rows={4} + maxLength={USER_BIO_MAX} {...field} /> )} diff --git a/src/platform/src/modules/user-profile/components/SecurityTab.tsx b/src/platform/src/modules/user-profile/components/SecurityTab.tsx index 4d2ad65d8c..c521ebf2b9 100644 --- a/src/platform/src/modules/user-profile/components/SecurityTab.tsx +++ b/src/platform/src/modules/user-profile/components/SecurityTab.tsx @@ -6,6 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Button, Input, Banner } from '@/shared/components/ui'; import { toast } from '@/shared/components/ui'; import { securitySchema, type SecurityFormData } from '@/shared/lib/validators'; +import { PASSWORD_MAX } from '@/shared/lib/validation-limits'; import { useUpdatePassword, useUser } from '@/shared/hooks'; import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; import { isInOrganizationContext } from '@/shared/utils/groupUtils'; @@ -97,6 +98,7 @@ const SecurityTab: React.FC = () => { error={errors.currentPassword?.message} required showPasswordToggle + maxLength={PASSWORD_MAX} /> {/* New Password */} @@ -108,6 +110,7 @@ const SecurityTab: React.FC = () => { error={errors.newPassword?.message} required showPasswordToggle + maxLength={PASSWORD_MAX} /> {/* Confirm Password */} @@ -119,6 +122,7 @@ const SecurityTab: React.FC = () => { error={errors.confirmPassword?.message} required showPasswordToggle + maxLength={PASSWORD_MAX} /> {/* Submit Button */} diff --git a/src/platform/src/shared/components/BulkRoleAssignmentDialog.tsx b/src/platform/src/shared/components/BulkRoleAssignmentDialog.tsx index e80f25f3c3..19301b87c2 100644 --- a/src/platform/src/shared/components/BulkRoleAssignmentDialog.tsx +++ b/src/platform/src/shared/components/BulkRoleAssignmentDialog.tsx @@ -6,6 +6,7 @@ import { useUser } from '@/shared/hooks/useUser'; import { useRolesByGroup, useAssignUsersToRole } from '@/shared/hooks'; import { toast } from '@/shared/components/ui/toast'; import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import { SEARCH_TERM_MAX } from '@/shared/lib/validation-limits'; interface BulkRoleAssignmentDialogProps { isOpen: boolean; @@ -209,6 +210,7 @@ const BulkRoleAssignmentDialog: React.FC = ({ }} placeholder="Search and select a role..." disabled={rolesLoading || !!rolesError || !activeGroup?.id} + maxLength={SEARCH_TERM_MAX} className="w-full px-3 py-2.5 border rounded-md text-sm bg-background text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none disabled:bg-muted disabled:text-muted-foreground" /> diff --git a/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx b/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx index 0f3a2d6465..78cd323912 100644 --- a/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx +++ b/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx @@ -12,6 +12,7 @@ import { setPasswordSchema, type SetPasswordFormData, } from '@/shared/lib/validators'; +import { PASSWORD_MAX } from '@/shared/lib/validation-limits'; import type { AuthMethods } from '@/shared/types/api'; const DISMISS_STORAGE_KEY_PREFIX = 'airqo:set-password-dismissed:'; @@ -308,6 +309,7 @@ const SetPasswordPromptDialog = () => { error={errors.password?.message} required showPasswordToggle + maxLength={PASSWORD_MAX} /> { error={errors.confirmPassword?.message} required showPasswordToggle + maxLength={PASSWORD_MAX} />

diff --git a/src/platform/src/shared/components/sidebar/config/index.ts b/src/platform/src/shared/components/sidebar/config/index.ts index 685a86636a..551a703be6 100644 --- a/src/platform/src/shared/components/sidebar/config/index.ts +++ b/src/platform/src/shared/components/sidebar/config/index.ts @@ -253,7 +253,7 @@ const systemSidebarConfig: NavGroup[] = [ }, { id: 'system-team-members', - label: 'Team Members', + label: 'Members', href: '/system/team-members', icon: AqUsers01, }, @@ -287,6 +287,12 @@ const systemSidebarConfig: NavGroup[] = [ href: '/system/surveys', icon: AqFileQuestion02, }, + { + id: 'system-users', + label: 'User Management', + href: '/system/users', + icon: AqUsers01, + }, { id: 'system-user-statistics', label: 'User Statistics', @@ -384,11 +390,17 @@ const globalSidebarConfig: NavGroup[] = [ description: 'Review active surveys, responses, and completion stats', }, + { + id: 'system-users', + label: 'User Management', + href: '/system/users', + description: 'View and manage platform users and roles', + }, { id: 'system-user-statistics', label: 'User Statistics', href: '/system/user-statistics', - description: 'View user statistics across the platform', + description: 'View analytics and charts for platform users', }, ], }, diff --git a/src/platform/src/shared/components/ui/search-field.tsx b/src/platform/src/shared/components/ui/search-field.tsx index 3181b44698..ffa7600d46 100644 --- a/src/platform/src/shared/components/ui/search-field.tsx +++ b/src/platform/src/shared/components/ui/search-field.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { cn } from '@/shared/lib/utils'; import { AqSearchRefraction, AqXClose } from '@airqo/icons-react'; +import { SEARCH_TERM_MAX } from '@/shared/lib/validation-limits'; export type SearchFieldProps = React.InputHTMLAttributes & { onClear?: () => void; @@ -8,7 +9,16 @@ export type SearchFieldProps = React.InputHTMLAttributes & { }; const SearchField = React.forwardRef( - ({ className, onClear, showClearButton = true, ...props }, ref) => { + ( + { + className, + onClear, + showClearButton = true, + maxLength = SEARCH_TERM_MAX, + ...props + }, + ref + ) => { const hasValue = props.value && String(props.value).length > 0; const handleClear = () => { @@ -30,6 +40,7 @@ const SearchField = React.forwardRef(

({
{ ); }; +// Get all users (optionally filtered by email) +export const useUsers = (email?: string) => { + return useSWR( + email ? `admin/users?email=${email}` : 'admin/users', + () => userService.getUsers(email), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); +}; + // Get all roles and permissions summary across all pages export const useRolesSummary = () => { return useSWR( @@ -260,6 +272,7 @@ export const useUpdateUserRole = () => { key => typeof key === 'string' && (key.startsWith('user/details/') || + key.startsWith('admin/users') || key === 'admin/roles/summary/all' || key === 'admin/user-statistics') ); diff --git a/src/platform/src/shared/lib/validation-limits.ts b/src/platform/src/shared/lib/validation-limits.ts new file mode 100644 index 0000000000..8ede16db9d --- /dev/null +++ b/src/platform/src/shared/lib/validation-limits.ts @@ -0,0 +1,76 @@ +/** + * Centralized, reasonable input length limits for the application. + * + * Use these constants in Zod schemas, HTML maxLength attributes, and custom + * validators so that short fields (e.g. first/last name) cannot accept + * paragraph-long input while longer text areas still have a sane upper bound. + */ + +// ─── User / Auth ─── +export const FIRST_NAME_MAX = 50; +export const LAST_NAME_MAX = 50; +export const FULL_NAME_MAX = 100; +export const EMAIL_MAX = 254; // RFC 5321 compliant upper bound +export const PASSWORD_MIN = 8; +export const PASSWORD_MAX = 128; +export const JOB_TITLE_MAX = 100; +export const PHONE_MAX = 30; +export const USER_BIO_MAX = 500; + +// ─── Organization / Group ─── +export const GROUP_TITLE_MAX = 120; +export const GROUP_DESCRIPTION_MAX = 500; +export const GROUP_INDUSTRY_MAX = 100; +export const GROUP_TIMEZONE_MAX = 100; +export const GROUP_WEBSITE_MAX = 200; + +// ─── Organization Request ─── +export const PROJECT_NAME_MAX = 120; +export const CITY_MAX = 100; +export const COUNTRY_MAX = 100; +export const FUNDER_PARTNER_MAX = 120; +export const CONTACT_NAME_MAX = 100; +export const USE_CASE_MAX = 1000; + +// ─── Roles ─── +export const ROLE_NAME_MAX = 50; +export const ROLE_CODE_MAX = 50; +export const ROLE_DESCRIPTION_MAX = 250; + +// ─── Security ─── +export const PROVIDER_NAME_MAX = 100; +export const ASN_MAX = 20; +export const CIDR_RANGE_MAX = 20; +export const BLOCK_REASON_MAX = 500; +export const RESOLUTION_NOTE_MAX = 1000; + +// ─── Feedback / Communication ─── +export const FEEDBACK_MESSAGE_MAX = 2000; +export const FEEDBACK_REPLY_MAX = 2000; +export const ADMIN_NOTES_MAX = 2000; +export const WATCHER_NAME_MAX = 100; + +// ─── Webhooks ─── +export const WEBHOOK_NAME_MAX = 100; +export const WEBHOOK_URL_MAX = 500; +export const WEBHOOK_SECRET_MAX = 255; + +// ─── Surveys ─── +export const SURVEY_TITLE_MAX = 120; +export const SURVEY_DESCRIPTION_MAX = 500; +export const SURVEY_QUESTION_MAX = 500; +export const SURVEY_OPTION_MAX = 1000; +export const SURVEY_PLACEHOLDER_MAX = 200; + +// ─── Data Visualizer ─── +export const CHART_TITLE_MAX = 120; +export const CHART_SUBTITLE_MAX = 200; +export const CHART_AXIS_LABEL_MAX = 100; +export const CHART_REFERENCE_LABEL_MAX = 100; +export const HEX_COLOR_MAX = 7; + +// ─── Email lists / Misc ─── +export const EMAIL_LIST_MAX = 2000; // comma/newline separated lists +export const SEARCH_TERM_MAX = 100; +export const TEXTAREA_DEFAULT_MAX = 2000; +export const REJECTION_REASON_MAX = 500; diff --git a/src/platform/src/shared/lib/validators.ts b/src/platform/src/shared/lib/validators.ts index ea8675e233..c812a006af 100644 --- a/src/platform/src/shared/lib/validators.ts +++ b/src/platform/src/shared/lib/validators.ts @@ -1,17 +1,52 @@ import { z } from 'zod'; +import { + FIRST_NAME_MAX, + LAST_NAME_MAX, + EMAIL_MAX, + PASSWORD_MIN, + PASSWORD_MAX, + JOB_TITLE_MAX, + USER_BIO_MAX, + PHONE_MAX, + CITY_MAX, + PROJECT_NAME_MAX, + FUNDER_PARTNER_MAX, + CONTACT_NAME_MAX, + USE_CASE_MAX, + COUNTRY_MAX, + GROUP_TITLE_MAX, + GROUP_DESCRIPTION_MAX, + GROUP_INDUSTRY_MAX, + GROUP_TIMEZONE_MAX, + GROUP_WEBSITE_MAX, + ROLE_NAME_MAX, + ROLE_DESCRIPTION_MAX, + ROLE_CODE_MAX, +} from './validation-limits'; export const loginSchema = z.object({ email: z.string().min(1, 'Email is required').email('Invalid email address'), - password: z.string().min(1, 'Password is required'), + password: z.string().min(1, 'Password is required').max(PASSWORD_MAX, `Password must be ${PASSWORD_MAX} characters or less`), }); export const registerSchema = z.object({ - firstName: z.string().min(1, 'First name is required'), - lastName: z.string().min(1, 'Last name is required'), - email: z.string().min(1, 'Email is required').email('Invalid email address'), + firstName: z + .string() + .min(1, 'First name is required') + .max(FIRST_NAME_MAX, `First name must be ${FIRST_NAME_MAX} characters or less`), + lastName: z + .string() + .min(1, 'Last name is required') + .max(LAST_NAME_MAX, `Last name must be ${LAST_NAME_MAX} characters or less`), + email: z + .string() + .min(1, 'Email is required') + .email('Invalid email address') + .max(EMAIL_MAX, `Email must be ${EMAIL_MAX} characters or less`), password: z .string() - .min(8, 'Password must be at least 8 characters') + .min(PASSWORD_MIN, `Password must be at least ${PASSWORD_MIN} characters`) + .max(PASSWORD_MAX, `Password must be ${PASSWORD_MAX} characters or less`) .regex( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, 'Password must include uppercase, lowercase, number, and special character' @@ -19,19 +54,24 @@ export const registerSchema = z.object({ }); export const forgotPwdSchema = z.object({ - email: z.string().min(1, 'Email is required').email('Invalid email address'), + email: z + .string() + .min(1, 'Email is required') + .email('Invalid email address') + .max(EMAIL_MAX, `Email must be ${EMAIL_MAX} characters or less`), }); export const resetPwdSchema = z .object({ password: z .string() - .min(8, 'Password must be at least 8 characters') + .min(PASSWORD_MIN, `Password must be at least ${PASSWORD_MIN} characters`) + .max(PASSWORD_MAX, `Password must be ${PASSWORD_MAX} characters or less`) .regex( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, 'Password must include uppercase, lowercase, number, and special character' ), - confirmPassword: z.string(), + confirmPassword: z.string().max(PASSWORD_MAX, `Password must be ${PASSWORD_MAX} characters or less`), }) .refine(d => d.password === d.confirmPassword, { message: 'Passwords do not match', @@ -43,12 +83,12 @@ export const setPasswordSchema = z password: z .string() .min(6, 'Password must be at least 6 characters') - .max(30, 'Password must be 30 characters or less') + .max(PASSWORD_MAX, `Password must be ${PASSWORD_MAX} characters or less`) .regex( /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@#?!$%^&*.,]+$/, 'Password must include at least one letter and one number' ), - confirmPassword: z.string(), + confirmPassword: z.string().max(PASSWORD_MAX, `Password must be ${PASSWORD_MAX} characters or less`), }) .refine(data => data.password === data.confirmPassword, { message: 'Passwords do not match', @@ -56,27 +96,38 @@ export const setPasswordSchema = z }); export const profileSchema = z.object({ - firstName: z.string().min(1, 'First name is required'), - lastName: z.string().min(1, 'Last name is required'), - email: z.string().min(1, 'Email is required').email('Invalid email address'), - phoneNumber: z.string().optional(), - jobTitle: z.string().optional(), + firstName: z + .string() + .min(1, 'First name is required') + .max(FIRST_NAME_MAX, `First name must be ${FIRST_NAME_MAX} characters or less`), + lastName: z + .string() + .min(1, 'Last name is required') + .max(LAST_NAME_MAX, `Last name must be ${LAST_NAME_MAX} characters or less`), + email: z + .string() + .min(1, 'Email is required') + .email('Invalid email address') + .max(EMAIL_MAX, `Email must be ${EMAIL_MAX} characters or less`), + phoneNumber: z.string().max(PHONE_MAX, `Phone number must be ${PHONE_MAX} characters or less`).optional(), + jobTitle: z.string().max(JOB_TITLE_MAX, `Job title must be ${JOB_TITLE_MAX} characters or less`).optional(), country: z.string().min(1, 'Country is required'), - description: z.string().optional(), + description: z.string().max(USER_BIO_MAX, `Bio must be ${USER_BIO_MAX} characters or less`).optional(), profilePicture: z.string().optional(), }); export const securitySchema = z .object({ - currentPassword: z.string().min(1, 'Current password is required'), + currentPassword: z.string().min(1, 'Current password is required').max(PASSWORD_MAX, `Password must be ${PASSWORD_MAX} characters or less`), newPassword: z .string() - .min(8, 'Password must be at least 8 characters') + .min(PASSWORD_MIN, `Password must be at least ${PASSWORD_MIN} characters`) + .max(PASSWORD_MAX, `Password must be ${PASSWORD_MAX} characters or less`) .regex( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, 'Password must include uppercase, lowercase, number, and special character' ), - confirmPassword: z.string(), + confirmPassword: z.string().max(PASSWORD_MAX, `Password must be ${PASSWORD_MAX} characters or less`), }) .refine(d => d.newPassword === d.confirmPassword, { message: 'Passwords do not match', @@ -88,33 +139,33 @@ export const organizationRequestSchema = z.object({ city: z .string() .min(2, 'City must be at least 2 characters') - .max(100, 'City must be less than 100 characters'), + .max(CITY_MAX, `City must be less than ${CITY_MAX} characters`), project_name: z .string() .min(2, 'Project name must be at least 2 characters') - .max(120, 'Project name must be less than 120 characters'), + .max(PROJECT_NAME_MAX, `Project name must be less than ${PROJECT_NAME_MAX} characters`), funder_partner: z .string() - .max(120, 'Funder/partner must be less than 120 characters') + .max(FUNDER_PARTNER_MAX, `Funder/partner must be less than ${FUNDER_PARTNER_MAX} characters`) .optional(), contact_email: z .string() .min(1, 'Email is required') .email('Please enter a valid email address') - .max(254, 'Email address is too long'), + .max(EMAIL_MAX, 'Email address is too long'), contact_name: z .string() .min(2, 'Contact name must be at least 2 characters') - .max(100, 'Contact name must be less than 100 characters'), + .max(CONTACT_NAME_MAX, `Contact name must be less than ${CONTACT_NAME_MAX} characters`), use_case: z .string() .min(10, 'Use case description must be at least 10 characters') - .max(1000, 'Use case description must be less than 1000 characters'), + .max(USE_CASE_MAX, `Use case description must be less than ${USE_CASE_MAX} characters`), organization_type: z.enum([ 'government', @@ -127,7 +178,51 @@ export const organizationRequestSchema = z.object({ country: z .string() .min(2, 'Country is required') - .max(100, 'Country name is too long'), + .max(COUNTRY_MAX, `Country name must be less than ${COUNTRY_MAX} characters`), +}); + +// Role creation validation +export const createRoleSchema = z.object({ + role_name: z + .string() + .min(1, 'Role name is required') + .max(ROLE_NAME_MAX, `Role name must be ${ROLE_NAME_MAX} characters or less`) + .regex( + /^[A-Z0-9_]+$/, + 'Role name must contain only uppercase letters, numbers, and underscores' + ), + role_code: z + .string() + .max(ROLE_CODE_MAX, `Role code must be ${ROLE_CODE_MAX} characters or less`) + .optional(), + role_description: z + .string() + .max(ROLE_DESCRIPTION_MAX, `Role description must be ${ROLE_DESCRIPTION_MAX} characters or less`) + .optional(), +}); + +// Group details validation +export const groupDetailsSchema = z.object({ + grp_title: z + .string() + .min(1, 'Group title is required') + .max(GROUP_TITLE_MAX, `Group title must be ${GROUP_TITLE_MAX} characters or less`), + grp_description: z + .string() + .max(GROUP_DESCRIPTION_MAX, `Description must be ${GROUP_DESCRIPTION_MAX} characters or less`) + .optional(), + grp_industry: z + .string() + .max(GROUP_INDUSTRY_MAX, `Industry must be ${GROUP_INDUSTRY_MAX} characters or less`) + .optional(), + grp_timezone: z + .string() + .max(GROUP_TIMEZONE_MAX, `Timezone must be ${GROUP_TIMEZONE_MAX} characters or less`) + .optional(), + grp_website: z + .string() + .max(GROUP_WEBSITE_MAX, `Website must be ${GROUP_WEBSITE_MAX} characters or less`) + .optional(), }); // Individual field validators for real-time validation @@ -153,6 +248,8 @@ export type SecurityFormData = z.infer; export type OrganizationRequestFormData = z.infer< typeof organizationRequestSchema >; +export type CreateRoleFormData = z.infer; +export type GroupDetailsFormData = z.infer; // IP Address validation export const isValidIpAddress = (ip: string): boolean => { diff --git a/src/platform/src/shared/services/userService.ts b/src/platform/src/shared/services/userService.ts index 9c643dfb3c..7ba5ff7eea 100644 --- a/src/platform/src/shared/services/userService.ts +++ b/src/platform/src/shared/services/userService.ts @@ -31,6 +31,7 @@ import type { UpdateGroupDetailsRequest, UpdateGroupDetailsResponse, GetUserStatisticsResponse, + GetUsersResponse, AcceptEmailInvitationRequest, AcceptEmailInvitationResponse, GetPendingInvitationsResponse, @@ -495,6 +496,22 @@ export class UserService { return data as GetUserStatisticsResponse; } + // Get users - authenticated endpoint (supports optional email filter) + async getUsers(email?: string): Promise { + await this.ensureAuthenticated(); + const params = email ? `?email=${encodeURIComponent(email.trim())}` : ''; + const response = await this.authenticatedClient.get< + GetUsersResponse | ApiErrorResponse + >(`/users${params}`); + const data = response.data; + + if ('success' in data && !data.success) { + throw new Error(data.message || 'Failed to get users'); + } + + return data as GetUsersResponse; + } + // Get pending invitations - authenticated endpoint async getPendingInvitations(): Promise { try { diff --git a/src/platform/src/shared/types/api.ts b/src/platform/src/shared/types/api.ts index 6431d1b559..2a9132132e 100644 --- a/src/platform/src/shared/types/api.ts +++ b/src/platform/src/shared/types/api.ts @@ -295,6 +295,12 @@ export interface UserDetailsResponse { users: User[]; } +export interface GetUsersResponse { + success: true; + message: string; + users: User[]; +} + export interface User { _id: string; firstName: string; From d763e90bf0d2399b118883c626da99d70bd0c343 Mon Sep 17 00:00:00 2001 From: Paul Ochieng Levi Date: Sun, 5 Jul 2026 22:42:09 +0300 Subject: [PATCH 2/6] feat: enhance user interface with descriptive titles for buttons and add StatsPieChart component --- .../app/(dashboard)/system/feedback/page.tsx | 1 + .../system/roles-permissions/page.tsx | 1 + .../app/(dashboard)/system/security/page.tsx | 3 + .../system/surveys/[surveyId]/page.tsx | 1 + .../(dashboard)/system/team-members/page.tsx | 5 +- .../system/user-statistics/page.tsx | 302 +++++++++++------- .../src/app/(dashboard)/system/users/page.tsx | 63 +--- .../charts/components/ui/StatsPieChart.tsx | 100 ++++++ .../src/shared/components/charts/index.ts | 2 + 9 files changed, 309 insertions(+), 169 deletions(-) create mode 100644 src/platform/src/shared/components/charts/components/ui/StatsPieChart.tsx diff --git a/src/platform/src/app/(dashboard)/system/feedback/page.tsx b/src/platform/src/app/(dashboard)/system/feedback/page.tsx index 96c33a5f60..0834d235fa 100644 --- a/src/platform/src/app/(dashboard)/system/feedback/page.tsx +++ b/src/platform/src/app/(dashboard)/system/feedback/page.tsx @@ -351,6 +351,7 @@ const FeedbackListContent: React.FC = () => { Icon={AqEye} iconPosition="start" onClick={() => handleViewFeedback(item._id)} + title="View feedback details" aria-label={`View feedback ${item.subject}`} > View diff --git a/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx b/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx index bba7593877..e6c0a53a50 100644 --- a/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx +++ b/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx @@ -198,6 +198,7 @@ const RolesPermissionsContent: React.FC = () => { variant="ghost" onClick={() => handleViewRole(item._id)} className="p-1 h-8 w-8" + title="Edit role" aria-label={`Edit role ${item.role_name}`} > diff --git a/src/platform/src/app/(dashboard)/system/security/page.tsx b/src/platform/src/app/(dashboard)/system/security/page.tsx index 2d9b7fabb3..cfaf959d4c 100644 --- a/src/platform/src/app/(dashboard)/system/security/page.tsx +++ b/src/platform/src/app/(dashboard)/system/security/page.tsx @@ -721,6 +721,7 @@ const SecurityPageContent: React.FC = () => { variant="ghost" onClick={() => handleEditBlockedAsn(item)} className="p-1 h-8 w-8" + title="Edit blocked range" aria-label={`Edit blocked range ${item.provider}`} > @@ -730,6 +731,7 @@ const SecurityPageContent: React.FC = () => { variant="ghost" onClick={() => handleDeleteBlockedAsn(item)} className="p-1 h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950" + title="Delete blocked range" aria-label={`Delete blocked range ${item.provider}`} > @@ -842,6 +844,7 @@ const SecurityPageContent: React.FC = () => { size="sm" variant="outlined" onClick={() => handleResolveToken(item)} + title="Resolve flagged token" > Resolve diff --git a/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx index a7d4270c48..83c121fa78 100644 --- a/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx +++ b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx @@ -558,6 +558,7 @@ const SurveyDetailsPage: React.FC = () => { Icon={AqEye} iconPosition="start" onClick={() => setSelectedResponse(item)} + title="View response details" > View diff --git a/src/platform/src/app/(dashboard)/system/team-members/page.tsx b/src/platform/src/app/(dashboard)/system/team-members/page.tsx index 9347638943..7ea5dd527f 100644 --- a/src/platform/src/app/(dashboard)/system/team-members/page.tsx +++ b/src/platform/src/app/(dashboard)/system/team-members/page.tsx @@ -14,6 +14,7 @@ import { } from '@/shared/utils/errorMessages'; import { AccessDenied } from '@/shared/components/AccessDenied'; import type { FeedbackStaffMember } from '@/shared/types/api'; +import { AqEye } from '@airqo/icons-react'; const MembersContent: React.FC = () => { const router = useRouter(); @@ -108,8 +109,10 @@ const MembersContent: React.FC = () => { onClick={() => router.push(`/system/team-members/${member._id}`) } + title="View member details" + aria-label="View member details" > - View + ), }, diff --git a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx index a64a3db1d6..ea46ccaf96 100644 --- a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx +++ b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx @@ -14,9 +14,10 @@ import { AccessDenied } from '@/shared/components/AccessDenied'; import { isForbiddenError } from '@/shared/utils/errorMessages'; import { toast } from '@/shared/components/ui/toast'; import { refreshWithToast } from '@/shared/utils/refreshWithToast'; -import { useUsers } from '@/shared/hooks/useAdmin'; +import { useUserStatistics, useUsers } from '@/shared/hooks/useAdmin'; import { formatWithPattern } from '@/shared/utils/dateUtils'; -import { ChartContainer } from '@/shared/components/charts'; +import { ChartContainer, StatsPieChart } from '@/shared/components/charts'; +import { getPrimaryColor } from '@/shared/components/charts/constants'; import { AqUsers01, AqUsersCheck, @@ -24,26 +25,19 @@ import { AqMail01, AqRefreshCw05, AqArrowRight, - AqPresentationChart02, } from '@airqo/icons-react'; import { - PieChart, - Pie, - Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, - Legend, ResponsiveContainer, LineChart, Line, } from 'recharts'; -const COLORS = ['#1649e5', '#059669', '#d97706', '#dc2626', '#7c3aed', '#0891b2']; - interface ChartDataPoint { name: string; value: number; @@ -51,17 +45,38 @@ interface ChartDataPoint { } const UserStatisticsPage: React.FC = () => { - const { data, isLoading, error, mutate } = useUsers(); + const { + data: statsResponse, + isLoading: statsLoading, + error: statsError, + mutate: mutateStats, + } = useUserStatistics(); + + const { + data: usersResponse, + isLoading: usersLoading, + error: usersError, + mutate: mutateUsers, + } = useUsers(); - const users = useMemo(() => data?.users ?? [], [data]); + const isLoading = statsLoading || usersLoading; + const error = statsError || usersError; const stats = useMemo(() => { - const total = users.length; - const active = users.filter(u => u.isActive).length; - const verified = users.filter(u => u.verified).length; - const apiUsers = users.filter(u => (u.clients?.length ?? 0) > 0).length; - return { total, active, verified, apiUsers }; - }, [users]); + const s = statsResponse?.users_stats; + return { + total: s?.users?.number ?? 0, + active: s?.active_users?.number ?? 0, + apiUsers: s?.api_users?.number ?? 0, + }; + }, [statsResponse]); + + const users = useMemo(() => usersResponse?.users ?? [], [usersResponse]); + + const verifiedCount = useMemo( + () => users.filter(u => u.verified).length, + [users] + ); const statusData: ChartDataPoint[] = useMemo( () => [ @@ -73,10 +88,10 @@ const UserStatisticsPage: React.FC = () => { const verificationData: ChartDataPoint[] = useMemo( () => [ - { name: 'Verified', value: stats.verified }, - { name: 'Unverified', value: stats.total - stats.verified }, + { name: 'Verified', value: verifiedCount }, + { name: 'Unverified', value: stats.total - verifiedCount }, ], - [stats] + [stats, verifiedCount] ); const organizationData: ChartDataPoint[] = useMemo(() => { @@ -126,7 +141,8 @@ const UserStatisticsPage: React.FC = () => { const topGroupsData: ChartDataPoint[] = useMemo(() => { const counts = users.reduce>((acc, user) => { (user.groups ?? []).forEach(group => { - const title = group.grp_title?.trim() || group.organization_slug || 'Unknown'; + const title = + group.grp_title?.trim() || group.organization_slug || 'Unknown'; acc[title] = (acc[title] ?? 0) + 1; }); return acc; @@ -140,7 +156,7 @@ const UserStatisticsPage: React.FC = () => { const handleRefresh = useCallback(async () => { try { await refreshWithToast( - () => mutate(), + () => Promise.all([mutateStats(), mutateUsers()]), 'User statistics refreshed successfully' ); } catch (err) { @@ -150,7 +166,7 @@ const UserStatisticsPage: React.FC = () => { : 'Unable to refresh user statistics' ); } - }, [mutate]); + }, [mutateStats, mutateUsers]); if (isLoading) { return ( @@ -233,7 +249,7 @@ const UserStatisticsPage: React.FC = () => {

Verified Users

-

{stats.verified}

+

{verifiedCount}

@@ -261,32 +277,7 @@ const UserStatisticsPage: React.FC = () => { showMoreButton={false} loading={isLoading} > - {stats.total > 0 ? ( - - - - - `${name}: ${value}`} - > - {statusData.map((_, index) => ( - - ))} - - - - ) : ( - - )} + { showMoreButton={false} loading={isLoading} > - {stats.total > 0 ? ( - - - - - `${name}: ${value}`} - > - {verificationData.map((_, index) => ( - - ))} - - - - ) : ( - - )} + { loading={isLoading} > {organizationData.length > 0 ? ( - - - - - - - + + + + + + + ) : ( @@ -351,13 +349,42 @@ const UserStatisticsPage: React.FC = () => { loading={isLoading} > {loginRangesData.some(d => d.value > 0) ? ( - - - - - - - + + + + + + + ) : ( @@ -372,18 +399,47 @@ const UserStatisticsPage: React.FC = () => { loading={isLoading} > {signupsOverTimeData.length > 0 ? ( - - - - - - + + + + + + @@ -399,13 +455,45 @@ const UserStatisticsPage: React.FC = () => { loading={isLoading} > {topGroupsData.length > 0 ? ( - - - - - - - + + + + + + + ) : ( @@ -418,9 +506,9 @@ const UserStatisticsPage: React.FC = () => { }; const NoDataState: React.FC = () => ( -
+
- +

No data available

diff --git a/src/platform/src/app/(dashboard)/system/users/page.tsx b/src/platform/src/app/(dashboard)/system/users/page.tsx index 0c236e008c..680f7c856d 100644 --- a/src/platform/src/app/(dashboard)/system/users/page.tsx +++ b/src/platform/src/app/(dashboard)/system/users/page.tsx @@ -4,7 +4,6 @@ import React, { useMemo, useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { Button, - Card, Input, LoadingState, PageHeading, @@ -28,10 +27,6 @@ import { AqRefreshCw05, AqDownload01, AqShield02, - AqUsers01, - AqUsersCheck, - AqMail01, - AqKey01, } from '@airqo/icons-react'; import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; @@ -103,14 +98,6 @@ const UserManagementPage: React.FC = () => { }); }, [availableRoles, roleSearch]); - const stats = useMemo(() => { - const total = users.length; - const active = users.filter(u => u.isActive).length; - const verified = users.filter(u => u.verified).length; - const apiUsers = users.filter(u => (u.clients?.length ?? 0) > 0).length; - return { total, active, verified, apiUsers }; - }, [users]); - const handleRefresh = useCallback(async () => { try { await refreshWithToast(() => mutate(), 'User list refreshed successfully'); @@ -348,6 +335,7 @@ const UserManagementPage: React.FC = () => { variant="ghost" size="sm" onClick={() => router.push(`/system/users/${user._id}`)} + title="View user details" aria-label="View user details" > @@ -357,6 +345,7 @@ const UserManagementPage: React.FC = () => { size="sm" onClick={() => openRoleDialog(user)} disabled={rolesLoading || availableRoles.length === 0} + title="Update user role" aria-label="Update user role" > @@ -432,54 +421,6 @@ const UserManagementPage: React.FC = () => { } /> - {/* Summary Cards */} -
- -
-
-

Total Users

-

{stats.total}

-
-
- -
-
-
- -
-
-

Active Users

-

{stats.active}

-
-
- -
-
-
- -
-
-

Verified Users

-

{stats.verified}

-
-
- -
-
-
- -
-
-

API Users

-

{stats.apiUsers}

-
-
- -
-
-
-
- {/* Users Table */} = ({ + data, + height = 300, + innerRadius = 0, + outerRadius = 100, + showLabel = true, + showLegend = true, + colors, +}) => { + if (!data || data.length === 0) { + return ( +
+

No data available

+
+ ); + } + + return ( + + + + {showLegend && ( + + )} + { + const name = String(props.name ?? ''); + const percent = Number(props.percent ?? 0); + return `${name} (${(percent * 100).toFixed(0)}%)`; + } + : undefined + } + > + {data.map((_, index) => ( + + ))} + + + + ); +}; + +export { StatsPieChart }; +export type { StatsPieChartProps }; diff --git a/src/platform/src/shared/components/charts/index.ts b/src/platform/src/shared/components/charts/index.ts index d50659254a..91d3f9daeb 100644 --- a/src/platform/src/shared/components/charts/index.ts +++ b/src/platform/src/shared/components/charts/index.ts @@ -12,6 +12,8 @@ export { CompactLegend, InteractiveLegend, } from './components/ui/CustomLegend'; +export { StatsPieChart } from './components/ui/StatsPieChart'; +export type { StatsPieChartDataPoint, StatsPieChartProps } from './components/ui/StatsPieChart'; // Export hooks export { useChartExport } from './hooks/useChartExport'; From b881430c8e960b069de06b4ec50680413ee37ddc Mon Sep 17 00:00:00 2001 From: Paul Ochieng Levi Date: Sun, 5 Jul 2026 22:54:30 +0300 Subject: [PATCH 3/6] feat: refactor user statistics charts for improved styling and consistency --- .../system/user-statistics/page.tsx | 151 +++++++++--------- 1 file changed, 77 insertions(+), 74 deletions(-) diff --git a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx index ea46ccaf96..a193576803 100644 --- a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx +++ b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx @@ -38,6 +38,26 @@ import { Line, } from 'recharts'; +const AXIS_STYLE = { + tick: { fontSize: 12, fill: 'rgb(100, 116, 139)' }, + tickLine: { stroke: 'rgb(226, 232, 240)' }, + axisLine: { stroke: 'rgb(226, 232, 240)' }, +}; + +const AXIS_LABEL_STYLE = { + textAnchor: 'start' as const, + fontSize: 12, + fill: 'rgb(100, 116, 139)', +}; + +const TOOLTIP_STYLE = { + backgroundColor: 'hsl(var(--card))', + border: '1px solid hsl(var(--border))', + borderRadius: '8px', + fontSize: '12px', + color: 'hsl(var(--card-foreground))', +}; + interface ChartDataPoint { name: string; value: number; @@ -299,7 +319,7 @@ const UserStatisticsPage: React.FC = () => { { /> - + @@ -352,7 +367,7 @@ const UserStatisticsPage: React.FC = () => { { /> - + @@ -402,7 +415,7 @@ const UserStatisticsPage: React.FC = () => { { /> - + @@ -458,7 +466,7 @@ const UserStatisticsPage: React.FC = () => { { /> - + From f074f029c75e3301be192d05360754b46f4ddc86 Mon Sep 17 00:00:00 2001 From: Paul Ochieng Levi Date: Mon, 6 Jul 2026 11:33:05 +0300 Subject: [PATCH 4/6] Refactor button loading states and improve code formatting across various components - Updated button components to use 'loading' prop instead of 'disabled' for better user experience during async operations. - Added 'isValidating' state to buttons in ClientsAdminPage, EmailConfigContent, OrganizationRequestsPage, RoleDetailContent, and SecurityPageContent for improved feedback during data fetching. - Cleaned up code formatting for better readability in multiple files, including user management and role assignment components. - Removed unnecessary comments and improved import statements for consistency. --- src/platform/docs/SELENIUM_TESTING.md | 44 ++-- .../user/(pages)/(layout1)/home/page.tsx | 8 +- .../system/clients/[clientId]/page.tsx | 2 +- .../app/(dashboard)/system/clients/page.tsx | 4 +- .../(dashboard)/system/email-configs/page.tsx | 203 +----------------- .../system/feedback/[feedbackId]/page.tsx | 5 +- .../(dashboard)/system/org-requests/page.tsx | 17 +- .../roles-permissions/[roleId]/page.tsx | 1 + .../components/CreateRoleDialog.tsx | 5 +- .../system/roles-permissions/page.tsx | 1 + .../app/(dashboard)/system/security/page.tsx | 2 + .../system/team-members/[memberId]/page.tsx | 31 +-- .../(dashboard)/system/team-members/page.tsx | 46 ++-- .../system/user-statistics/page.tsx | 25 +-- .../(dashboard)/system/users/[id]/page.tsx | 5 +- .../src/app/(dashboard)/system/users/page.tsx | 26 ++- .../components/BulkRoleAssignmentDialog.tsx | 18 +- .../charts/components/ui/StatsPieChart.tsx | 5 +- .../src/shared/components/charts/index.ts | 5 +- .../header/components/info-dropdown.tsx | 4 +- src/platform/src/shared/lib/metadata.ts | 3 +- src/platform/src/shared/lib/validators.ts | 112 ++++++++-- 22 files changed, 235 insertions(+), 337 deletions(-) diff --git a/src/platform/docs/SELENIUM_TESTING.md b/src/platform/docs/SELENIUM_TESTING.md index bffbf0cafe..c66f297fd9 100644 --- a/src/platform/docs/SELENIUM_TESTING.md +++ b/src/platform/docs/SELENIUM_TESTING.md @@ -175,28 +175,28 @@ npx mocha --require ts-node/register --timeout 60000 tests/data/data.test.ts ### Coverage by Module -| Module | Pages | Tests | Status | -| ---------------- | -------------------- | ------- | ------------------ | -| **Auth** | Login | 8 | Covered | -| | Register | 7 | Covered | -| | Forgot Password | 4 | Covered | -| | Reset Password | 4 | Covered | -| | Protected Routes | 7 | Covered | -| | Form Validation | 5 | Covered | -| **User** | Home | 8 | Covered | -| | Profile | 9 | Covered | -| | Favorites/Analytics | 2 | Covered | -| | Request Organization | 4 | Covered | -| **Organization** | Dashboard | 3 | Covered | -| | Members | 4 | Covered | -| | Settings | 2 | Covered | -| | Roles | 2 | Covered | -| | Member Requests | 3 | Covered | -| **Admin** | All System Pages | 7 | Page loads covered | -| **Data** | Visualizer | 2 | Covered | -| | Export | 3 | Covered | -| | Map | 2 | Covered | -| **Total** | | **86** | | +| Module | Pages | Tests | Status | +| ---------------- | -------------------- | ------ | ------------------ | +| **Auth** | Login | 8 | Covered | +| | Register | 7 | Covered | +| | Forgot Password | 4 | Covered | +| | Reset Password | 4 | Covered | +| | Protected Routes | 7 | Covered | +| | Form Validation | 5 | Covered | +| **User** | Home | 8 | Covered | +| | Profile | 9 | Covered | +| | Favorites/Analytics | 2 | Covered | +| | Request Organization | 4 | Covered | +| **Organization** | Dashboard | 3 | Covered | +| | Members | 4 | Covered | +| | Settings | 2 | Covered | +| | Roles | 2 | Covered | +| | Member Requests | 3 | Covered | +| **Admin** | All System Pages | 7 | Page loads covered | +| **Data** | Visualizer | 2 | Covered | +| | Export | 3 | Covered | +| | Map | 2 | Covered | +| **Total** | | **86** | | ### What Each Test Suite Covers diff --git a/src/platform/src/app/(dashboard)/(individual)/user/(pages)/(layout1)/home/page.tsx b/src/platform/src/app/(dashboard)/(individual)/user/(pages)/(layout1)/home/page.tsx index 0bc617a117..00fb90608e 100644 --- a/src/platform/src/app/(dashboard)/(individual)/user/(pages)/(layout1)/home/page.tsx +++ b/src/platform/src/app/(dashboard)/(individual)/user/(pages)/(layout1)/home/page.tsx @@ -17,8 +17,12 @@ export default function HomePage() { const [isModalOpen, setIsModalOpen] = useState(false); const [isDataAccessOpen, setIsDataAccessOpen] = useState(false); - const fairUsagePolicyUrl = useEnvironmentAwareUrl('https://platform.airqo.net/docs/data-access/fair-usage-policy/'); - const researchersGuideUrl = useEnvironmentAwareUrl('https://platform.airqo.net/docs/data-access/researchers-guide/'); + const fairUsagePolicyUrl = useEnvironmentAwareUrl( + 'https://platform.airqo.net/docs/data-access/fair-usage-policy/' + ); + const researchersGuideUrl = useEnvironmentAwareUrl( + 'https://platform.airqo.net/docs/data-access/researchers-guide/' + ); const handleModal = () => setIsModalOpen(!isModalOpen); diff --git a/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx b/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx index 9aa4bc3fb7..e6a4b527f4 100644 --- a/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx +++ b/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx @@ -899,7 +899,7 @@ const ClientDetailsPage: React.FC = () => { diff --git a/src/platform/src/app/(dashboard)/system/clients/page.tsx b/src/platform/src/app/(dashboard)/system/clients/page.tsx index 4c66b34b85..fc69859be9 100644 --- a/src/platform/src/app/(dashboard)/system/clients/page.tsx +++ b/src/platform/src/app/(dashboard)/system/clients/page.tsx @@ -86,6 +86,7 @@ const ClientsAdminPage: React.FC = () => { const { data: clientsResponse, isLoading, + isValidating, error, mutate, } = useSWR('/api/clients', () => clientService.getClients()); @@ -437,6 +438,7 @@ const ClientsAdminPage: React.FC = () => { variant="outlined" Icon={AqRefreshCw05} onClick={handleRefresh} + loading={isValidating} className="ml-auto" > Refresh @@ -559,7 +561,7 @@ const ClientsAdminPage: React.FC = () => { diff --git a/src/platform/src/app/(dashboard)/system/email-configs/page.tsx b/src/platform/src/app/(dashboard)/system/email-configs/page.tsx index e2a785667e..ecc0bb79dd 100644 --- a/src/platform/src/app/(dashboard)/system/email-configs/page.tsx +++ b/src/platform/src/app/(dashboard)/system/email-configs/page.tsx @@ -141,6 +141,7 @@ const EmailConfigContent: React.FC = () => { data: response, error, isLoading, + isValidating, mutate, } = useSWR( '/users/application-email-configs', @@ -536,6 +537,7 @@ const EmailConfigContent: React.FC = () => { Icon={AqRefreshCw05} iconPosition="start" onClick={handleRefresh} + loading={isValidating} > Refresh @@ -593,6 +595,7 @@ const EmailConfigContent: React.FC = () => { onClick={handleRefresh} Icon={AqRefreshCw05} iconPosition="start" + loading={isValidating} > Try again @@ -664,6 +667,7 @@ const EmailConfigContent: React.FC = () => { placeholder="admin1@airqo.net, admin2@airqo.net" description="Comma-separated admin emails to copy on automated alerts." required={dialogState?.mode === 'create'} + maxLength={EMAIL_LIST_MAX} /> {dialogState?.mode === 'edit' && ( @@ -706,6 +710,7 @@ const EmailConfigContent: React.FC = () => { } placeholder={'app-client@airqo.net\nmonitoring-bot@airqo.net'} rows={6} + maxLength={EMAIL_LIST_MAX} />

{dialogState?.mode === 'edit' @@ -792,202 +797,8 @@ const EmailConfigContent: React.FC = () => {

- - ) : ( -
-
- {summaryCards.map(card => ( - -

{card.title}

-

- {card.value} -

-

- {card.description} -

-
- ))} -
- - {configs.length === 0 ? ( - } - action={{ - label: 'Create configuration', - onClick: openCreateDialog, - variant: 'filled', - }} - /> - ) : ( - - )} -
- )} - - -
- - setFormState(prev => ({ - ...prev, - adminCCEmails: String(event.target.value || ''), - })) - } - placeholder="admin1@airqo.net, admin2@airqo.net" - description="Comma-separated admin emails to copy on automated alerts." - required={dialogState?.mode === 'create'} - maxLength={EMAIL_LIST_MAX} - /> - - {dialogState?.mode === 'edit' && ( - - )} - -
- - setFormState(prev => ({ - ...prev, - applicationEmails: String(event.target.value || ''), - })) - } - placeholder={'app-client@airqo.net\nmonitoring-bot@airqo.net'} - rows={6} - maxLength={EMAIL_LIST_MAX} - /> -

- {dialogState?.mode === 'edit' - ? formState.mode === 'add' - ? 'Add one email per line. These emails will be appended to the existing list.' - : formState.mode === 'remove' - ? 'Add one email per line. These emails will be removed from the existing list.' - : 'Add one email per line. Leaving this blank will clear the current application email list.' - : 'Optional. Add one email per line.'} -

-
- -
- - -
-
-
- - -
-

- This will permanently remove the configuration for{' '} - - {deleteConfig ? shortenId(deleteConfig._id) : ''} - - . Any automated alerts using this config will stop copying the - configured admin CC recipients. -

- - {deleteConfig && ( -
-
-

- Admin CC emails -

-

- {parseEmailList(deleteConfig.adminCCEmails || '').length} -

-
-
-

- Application emails -

-

- {(deleteConfig.applicationEmails || []).length} -

-
-
- )} - -
- - -
-
-
-
+ +
)} ); diff --git a/src/platform/src/app/(dashboard)/system/feedback/[feedbackId]/page.tsx b/src/platform/src/app/(dashboard)/system/feedback/[feedbackId]/page.tsx index 597d4ef7ae..95b406a320 100644 --- a/src/platform/src/app/(dashboard)/system/feedback/[feedbackId]/page.tsx +++ b/src/platform/src/app/(dashboard)/system/feedback/[feedbackId]/page.tsx @@ -31,10 +31,7 @@ import { isForbiddenError, } from '@/shared/utils/errorMessages'; import { AccessDenied } from '@/shared/components/AccessDenied'; -import { - EMAIL_MAX, - WATCHER_NAME_MAX, -} from '@/shared/lib/validation-limits'; +import { EMAIL_MAX, WATCHER_NAME_MAX } from '@/shared/lib/validation-limits'; import type { FeedbackSubmission, FeedbackReply, diff --git a/src/platform/src/app/(dashboard)/system/org-requests/page.tsx b/src/platform/src/app/(dashboard)/system/org-requests/page.tsx index 291ca78984..b4fd7fdb9c 100644 --- a/src/platform/src/app/(dashboard)/system/org-requests/page.tsx +++ b/src/platform/src/app/(dashboard)/system/org-requests/page.tsx @@ -80,7 +80,8 @@ const getPartnerName = (request: OrganizationRequest) => request.funder_partner || request.funderPartner || ''; const OrganizationRequestsPage = () => { - const { data, error, isLoading, mutate } = useOrganizationRequests(); + const { data, error, isLoading, isValidating, mutate } = + useOrganizationRequests(); const requests = useMemo(() => data?.requests || [], [data?.requests]); const errorMessage = error ? 'Failed to load organization requests' : null; @@ -363,7 +364,12 @@ const OrganizationRequestsPage = () => { title="Failed to load organization requests" message={errorMessage} actions={ - } @@ -404,7 +410,12 @@ const OrganizationRequestsPage = () => { {requests.filter(r => r.status === 'rejected').length})
- diff --git a/src/platform/src/app/(dashboard)/system/roles-permissions/[roleId]/page.tsx b/src/platform/src/app/(dashboard)/system/roles-permissions/[roleId]/page.tsx index 87f454563a..c5b3472d5b 100644 --- a/src/platform/src/app/(dashboard)/system/roles-permissions/[roleId]/page.tsx +++ b/src/platform/src/app/(dashboard)/system/roles-permissions/[roleId]/page.tsx @@ -236,6 +236,7 @@ const RoleDetailContent: React.FC = () => { size="sm" Icon={AqRefreshCw05} onClick={handleRefresh} + loading={roleLoading || permissionsLoading} > Refresh diff --git a/src/platform/src/app/(dashboard)/system/roles-permissions/components/CreateRoleDialog.tsx b/src/platform/src/app/(dashboard)/system/roles-permissions/components/CreateRoleDialog.tsx index 9dc0f6aa8d..d231832f3d 100644 --- a/src/platform/src/app/(dashboard)/system/roles-permissions/components/CreateRoleDialog.tsx +++ b/src/platform/src/app/(dashboard)/system/roles-permissions/components/CreateRoleDialog.tsx @@ -13,10 +13,7 @@ import { toast } from '@/shared/components/ui'; import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; import { useCreateRole } from '@/shared/hooks/useAdmin'; import type { UserRoleSummary } from '@/shared/types/api'; -import { - ROLE_NAME_MAX, - ROLE_CODE_MAX, -} from '@/shared/lib/validation-limits'; +import { ROLE_NAME_MAX, ROLE_CODE_MAX } from '@/shared/lib/validation-limits'; interface CreateRoleDialogProps { isOpen: boolean; diff --git a/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx b/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx index 51403f2400..247a2cbfc8 100644 --- a/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx +++ b/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx @@ -317,6 +317,7 @@ const RolesPermissionsContent: React.FC = () => { size="sm" Icon={AqRefreshCw05} onClick={handleRefresh} + loading={rolesLoading} > Refresh diff --git a/src/platform/src/app/(dashboard)/system/security/page.tsx b/src/platform/src/app/(dashboard)/system/security/page.tsx index 0eb6ee2105..1a119aefb3 100644 --- a/src/platform/src/app/(dashboard)/system/security/page.tsx +++ b/src/platform/src/app/(dashboard)/system/security/page.tsx @@ -966,6 +966,7 @@ const SecurityPageContent: React.FC = () => { variant="outlined" Icon={AqRefreshCw05} onClick={handleRefreshBlocked} + loading={blockedLoading} className="ml-auto" > Refresh @@ -1016,6 +1017,7 @@ const SecurityPageContent: React.FC = () => { variant="outlined" Icon={AqRefreshCw05} onClick={handleRefreshFlagged} + loading={flaggedLoading} className="ml-auto" > Refresh diff --git a/src/platform/src/app/(dashboard)/system/team-members/[memberId]/page.tsx b/src/platform/src/app/(dashboard)/system/team-members/[memberId]/page.tsx index 96c374fdf0..086711a645 100644 --- a/src/platform/src/app/(dashboard)/system/team-members/[memberId]/page.tsx +++ b/src/platform/src/app/(dashboard)/system/team-members/[memberId]/page.tsx @@ -1,6 +1,12 @@ 'use client'; -import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react'; +import React, { + useMemo, + useState, + useCallback, + useRef, + useEffect, +} from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useSWRConfig } from 'swr'; import { @@ -40,9 +46,7 @@ const DetailItem: React.FC<{ label: string; value: string }> = ({ value, }) => (
- +

{value}

); @@ -339,10 +343,7 @@ const TeamMemberDetailContent: React.FC<{ memberId: string }> = ({ : 'Never' } /> - + = ({

Role groups loaded

-

- {availableRoles.length} -

+

{availableRoles.length}

{rolesError ? ( @@ -395,7 +394,11 @@ const TeamMemberDetailContent: React.FC<{ memberId: string }> = ({ onClick={openRoleDialog} fullWidth loading={rolesLoading} - disabled={rolesLoading || availableRoles.length === 0 || !activeGroup?.id} + disabled={ + rolesLoading || + availableRoles.length === 0 || + !activeGroup?.id + } > Change Role @@ -614,9 +617,7 @@ const TeamMemberDetailContent: React.FC<{ memberId: string }> = ({ ? 'bg-primary/20 text-primary font-medium' : 'text-foreground' } ${ - highlightedRoleIndex === index - ? 'bg-primary/10' - : '' + highlightedRoleIndex === index ? 'bg-primary/10' : '' }`} > {role.role_name} - {role.group?.grp_title || 'No group'} diff --git a/src/platform/src/app/(dashboard)/system/team-members/page.tsx b/src/platform/src/app/(dashboard)/system/team-members/page.tsx index 7ea5dd527f..9fe2c78ef0 100644 --- a/src/platform/src/app/(dashboard)/system/team-members/page.tsx +++ b/src/platform/src/app/(dashboard)/system/team-members/page.tsx @@ -4,7 +4,12 @@ import React, { useState, useMemo, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import useSWR, { useSWRConfig } from 'swr'; import { PermissionGuard } from '@/shared/components'; -import { Button, Card, LoadingState, PageHeading } from '@/shared/components/ui'; +import { + Button, + Card, + LoadingState, + PageHeading, +} from '@/shared/components/ui'; import { ServerSideTable } from '@/shared/components/ui/server-side-table'; import BulkRoleAssignmentDialog from '@/shared/components/BulkRoleAssignmentDialog'; import { feedbackService } from '@/modules/feedback'; @@ -34,29 +39,21 @@ const MembersContent: React.FC = () => { data: staffData, isLoading, error, - } = useSWR( - 'feedback/staff', - () => feedbackService.getFeedbackStaff(), - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - dedupingInterval: 60000, - } - ); + } = useSWR('feedback/staff', () => feedbackService.getFeedbackStaff(), { + revalidateOnFocus: false, + revalidateOnReconnect: false, + dedupingInterval: 60000, + }); - const allMembers = useMemo( - () => staffData?.staff || [], - [staffData?.staff] - ); + const allMembers = useMemo(() => staffData?.staff || [], [staffData?.staff]); const filteredMembers = useMemo(() => { if (!search.trim()) return allMembers; const q = search.trim().toLowerCase(); - return allMembers.filter( - (member: FeedbackStaffMember) => - `${member.firstName} ${member.lastName} ${member.email} ${member.userName}` - .toLowerCase() - .includes(q) + return allMembers.filter((member: FeedbackStaffMember) => + `${member.firstName} ${member.lastName} ${member.email} ${member.userName}` + .toLowerCase() + .includes(q) ); }, [allMembers, search]); @@ -106,9 +103,7 @@ const MembersContent: React.FC = () => { @@ -346,10 +348,7 @@ const UserStatisticsPage: React.FC = () => { axisLine={AXIS_STYLE.axisLine} /> - + ) : ( @@ -394,10 +393,7 @@ const UserStatisticsPage: React.FC = () => { axisLine={AXIS_STYLE.axisLine} /> - + ) : ( @@ -493,10 +489,7 @@ const UserStatisticsPage: React.FC = () => { axisLine={AXIS_STYLE.axisLine} /> - + ) : ( diff --git a/src/platform/src/app/(dashboard)/system/users/[id]/page.tsx b/src/platform/src/app/(dashboard)/system/users/[id]/page.tsx index 4d79f2203f..f393f4a4c2 100644 --- a/src/platform/src/app/(dashboard)/system/users/[id]/page.tsx +++ b/src/platform/src/app/(dashboard)/system/users/[id]/page.tsx @@ -26,7 +26,10 @@ import { AqUsersCheck, } from '@airqo/icons-react'; import { formatWithPattern } from '@/shared/utils/dateUtils'; -import { getUserFriendlyErrorMessage, isForbiddenError } from '@/shared/utils/errorMessages'; +import { + getUserFriendlyErrorMessage, + isForbiddenError, +} from '@/shared/utils/errorMessages'; import { AccessDenied } from '@/shared/components/AccessDenied'; import { SEARCH_TERM_MAX } from '@/shared/lib/validation-limits'; import { refreshWithToast } from '@/shared/utils/refreshWithToast'; diff --git a/src/platform/src/app/(dashboard)/system/users/page.tsx b/src/platform/src/app/(dashboard)/system/users/page.tsx index 680f7c856d..aef48f436f 100644 --- a/src/platform/src/app/(dashboard)/system/users/page.tsx +++ b/src/platform/src/app/(dashboard)/system/users/page.tsx @@ -100,7 +100,10 @@ const UserManagementPage: React.FC = () => { const handleRefresh = useCallback(async () => { try { - await refreshWithToast(() => mutate(), 'User list refreshed successfully'); + await refreshWithToast( + () => mutate(), + 'User list refreshed successfully' + ); } catch (err) { toast.error(getUserFriendlyErrorMessage(err)); } @@ -184,7 +187,9 @@ const UserManagementPage: React.FC = () => { escape(user.country || ''), escape(user.jobTitle || ''), escape( - (user.groups ?? []).map(g => g.grp_title || g.organization_slug).join('; ') + (user.groups ?? []) + .map(g => g.grp_title || g.organization_slug) + .join('; ') ), ]); @@ -359,10 +364,7 @@ const UserManagementPage: React.FC = () => { if (isLoading) { return ( - + ); } @@ -381,7 +383,11 @@ const UserManagementPage: React.FC = () => { title="Failed to load users" message={error?.message || 'An error occurred while loading users'} /> - @@ -478,7 +484,11 @@ const UserManagementPage: React.FC = () => { />