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/(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)/(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)/(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}
/>
@@ -212,6 +214,7 @@ export default function OrgLoginPage() {
error={errors.password?.message}
placeholder="Enter your password"
showPasswordToggle
+ maxLength={PASSWORD_MAX}
/>
diff --git a/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(pages)/(layout1)/members/page.tsx b/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(pages)/(layout1)/members/page.tsx
index e883477da0..76d7a2f4fc 100644
--- a/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(pages)/(layout1)/members/page.tsx
+++ b/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(pages)/(layout1)/members/page.tsx
@@ -25,6 +25,7 @@ import {
import { useRouter, useParams } from 'next/navigation';
import { PermissionGuard } from '@/shared/components';
import { useRBAC } from '@/shared/hooks';
+import { EMAIL_MAX } from '@/shared/lib/validation-limits';
interface GroupMember {
_id: string;
@@ -464,6 +465,7 @@ const MembersPage: React.FC = () => {
}
placeholder="user@example.com"
className="w-full"
+ maxLength={EMAIL_MAX}
/>
{emailErrors[index] && (
diff --git a/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(pages)/(layout1)/roles/page.tsx b/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(pages)/(layout1)/roles/page.tsx
index a3e91674e0..98bdc9c76b 100644
--- a/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(pages)/(layout1)/roles/page.tsx
+++ b/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(pages)/(layout1)/roles/page.tsx
@@ -19,6 +19,10 @@ import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
import { formatWithPattern } from '@/shared/utils/dateUtils';
import { useUser } from '@/shared/hooks/useUser';
import { useRBAC } from '@/shared/hooks';
+import {
+ ROLE_NAME_MAX,
+ ROLE_DESCRIPTION_MAX,
+} from '@/shared/lib/validation-limits';
const RolesPage = () => {
const router = useRouter();
@@ -299,6 +303,7 @@ const RolesPage = () => {
placeholder="EXAMPLE_ROLE_NAME"
className="w-full"
disabled={createRoleMutation.isMutating}
+ maxLength={ROLE_NAME_MAX}
/>
@@ -312,6 +317,7 @@ const RolesPage = () => {
placeholder="Enter role description (optional)"
className="w-full"
disabled={createRoleMutation.isMutating}
+ maxLength={ROLE_DESCRIPTION_MAX}
/>
{currentOrg && (
diff --git a/src/platform/src/app/(dashboard)/request-organization/page.tsx b/src/platform/src/app/(dashboard)/request-organization/page.tsx
index 96d2a575b0..8e9ed488fb 100644
--- a/src/platform/src/app/(dashboard)/request-organization/page.tsx
+++ b/src/platform/src/app/(dashboard)/request-organization/page.tsx
@@ -19,6 +19,14 @@ import { toast } from '@/shared/components/ui/toast';
import Dialog from '@/shared/components/ui/dialog';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
import { validateEmail } from '@/shared/lib/validators';
+import {
+ PROJECT_NAME_MAX,
+ CITY_MAX,
+ FUNDER_PARTNER_MAX,
+ CONTACT_NAME_MAX,
+ EMAIL_MAX,
+ USE_CASE_MAX,
+} from '@/shared/lib/validation-limits';
const ORGANIZATION_TYPES = [
{ value: 'government', label: 'Government' },
@@ -239,6 +247,7 @@ const RequestOrganizationPage = () => {
required
placeholder="Clean Air Monitor"
error={validationErrors.project_name}
+ maxLength={PROJECT_NAME_MAX}
/>
@@ -250,6 +259,7 @@ const RequestOrganizationPage = () => {
required
placeholder="Kampala"
error={validationErrors.city}
+ maxLength={CITY_MAX}
/>
{
onChange={handleInputChange}
placeholder="WorldBank (optional)"
description="Optional: sponsor or implementation partner."
+ maxLength={FUNDER_PARTNER_MAX}
/>
@@ -304,6 +315,7 @@ const RequestOrganizationPage = () => {
rows={5}
placeholder="Describe what you are monitoring, who will use the data, and expected impact."
error={validationErrors.use_case}
+ maxLength={USE_CASE_MAX}
/>
@@ -325,6 +337,7 @@ const RequestOrganizationPage = () => {
required
placeholder="Jane Doe"
error={validationErrors.contact_name}
+ maxLength={CONTACT_NAME_MAX}
/>
{
required
placeholder="contact@example.com"
error={validationErrors.contact_email}
+ maxLength={EMAIL_MAX}
/>
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 = () => {
{isRefreshingSecret ? 'Regenerating...' : 'Regenerate Secret'}
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 = () => {
{isRefreshingSecret ? 'Regenerating...' : 'Regenerate Secret'}
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 8b95e84de9..ecc0bb79dd 100644
--- a/src/platform/src/app/(dashboard)/system/email-configs/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/email-configs/page.tsx
@@ -33,6 +33,7 @@ import {
getUserFriendlyErrorMessage,
isForbiddenError,
} from '@/shared/utils/errorMessages';
+import { EMAIL_LIST_MAX } from '@/shared/lib/validation-limits';
import { AccessDenied } from '@/shared/components/AccessDenied';
import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging';
@@ -140,6 +141,7 @@ const EmailConfigContent: React.FC = () => {
data: response,
error,
isLoading,
+ isValidating,
mutate,
} = useSWR(
'/users/application-email-configs',
@@ -535,6 +537,7 @@ const EmailConfigContent: React.FC = () => {
Icon={AqRefreshCw05}
iconPosition="start"
onClick={handleRefresh}
+ loading={isValidating}
>
Refresh
@@ -592,6 +595,7 @@ const EmailConfigContent: React.FC = () => {
onClick={handleRefresh}
Icon={AqRefreshCw05}
iconPosition="start"
+ loading={isValidating}
>
Try again
@@ -663,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' && (
@@ -705,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'
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 3a69c8d87e..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,6 +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 type {
FeedbackSubmission,
FeedbackReply,
@@ -899,6 +900,7 @@ const FeedbackDetailsContent: React.FC<{ feedbackId: string }> = ({
(e as React.ChangeEvent).target.value
)
}
+ maxLength={EMAIL_MAX}
/>
= ({
(e as React.ChangeEvent).target.value
)
}
+ maxLength={WATCHER_NAME_MAX}
/>
{
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/feedback/webhooks/page.tsx b/src/platform/src/app/(dashboard)/system/feedback/webhooks/page.tsx
index 568901dbd0..79b9b0fc0b 100644
--- a/src/platform/src/app/(dashboard)/system/feedback/webhooks/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/feedback/webhooks/page.tsx
@@ -19,6 +19,11 @@ import {
} from '@/shared/utils/errorMessages';
import { AccessDenied } from '@/shared/components/AccessDenied';
import type { FeedbackWebhook } from '@/shared/types/api';
+import {
+ WEBHOOK_NAME_MAX,
+ WEBHOOK_URL_MAX,
+ WEBHOOK_SECRET_MAX,
+} from '@/shared/lib/validation-limits';
const WEBHOOK_EVENT_OPTIONS = [
{ value: 'feedback.submitted', label: 'Feedback Submitted' },
@@ -91,6 +96,7 @@ const WebhookForm: React.FC<{
onChange={e =>
setName((e as React.ChangeEvent).target.value)
}
+ maxLength={WEBHOOK_NAME_MAX}
/>
setUrl((e as React.ChangeEvent).target.value)
}
+ maxLength={WEBHOOK_URL_MAX}
/>
{!initialData && (
setSecret((e as React.ChangeEvent).target.value)
}
+ maxLength={WEBHOOK_SECRET_MAX}
/>
)}
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 e96af6b771..b4fd7fdb9c 100644
--- a/src/platform/src/app/(dashboard)/system/org-requests/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/org-requests/page.tsx
@@ -14,6 +14,7 @@ import {
isForbiddenError,
} from '@/shared/utils/errorMessages';
import { AccessDenied } from '@/shared/components/AccessDenied';
+import { REJECTION_REASON_MAX } from '@/shared/lib/validation-limits';
import { refreshWithToast } from '@/shared/utils/refreshWithToast';
import {
useOrganizationRequests,
@@ -79,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;
@@ -362,7 +364,12 @@ const OrganizationRequestsPage = () => {
title="Failed to load organization requests"
message={errorMessage}
actions={
-
+
Try again
}
@@ -403,7 +410,12 @@ const OrganizationRequestsPage = () => {
{requests.filter(r => r.status === 'rejected').length})
-
+
Refresh
@@ -476,6 +488,7 @@ const OrganizationRequestsPage = () => {
}
required
placeholder="Please provide a reason for rejection"
+ maxLength={REJECTION_REASON_MAX}
/>
)}
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 a42089914b..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,6 +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';
interface CreateRoleDialogProps {
isOpen: boolean;
@@ -155,6 +156,7 @@ const CreateRoleDialog: React.FC = ({
}
placeholder="EXAMPLE_ROLE_NAME"
className="w-full"
+ maxLength={ROLE_NAME_MAX}
/>
@@ -167,6 +169,7 @@ const CreateRoleDialog: React.FC = ({
onChange={e => setRoleCode(e.target.value)}
placeholder="Optional identifier code"
className="w-full"
+ maxLength={ROLE_CODE_MAX}
/>
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 4166dbdad0..247a2cbfc8 100644
--- a/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/roles-permissions/page.tsx
@@ -201,6 +201,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}`}
>
@@ -316,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 88f10b3d92..1a119aefb3 100644
--- a/src/platform/src/app/(dashboard)/system/security/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/security/page.tsx
@@ -25,6 +25,13 @@ import { AccessDenied } from '@/shared/components/AccessDenied';
import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging';
import { formatDate } from '@/shared/utils';
import { isValidAsn, isValidCidrNotation } from '@/shared/lib/validators';
+import {
+ PROVIDER_NAME_MAX,
+ ASN_MAX,
+ CIDR_RANGE_MAX,
+ BLOCK_REASON_MAX,
+ RESOLUTION_NOTE_MAX,
+} from '@/shared/lib/validation-limits';
import { refreshWithToast } from '@/shared/utils/refreshWithToast';
import type {
BlockedAsn,
@@ -116,6 +123,7 @@ const StringListEditor: React.FC = ({
onChange={e => onChange(index, e.target.value)}
placeholder={placeholder}
className="w-full"
+ maxLength={CIDR_RANGE_MAX}
/>
{errors[index] && (
@@ -305,6 +313,7 @@ const BlockedAsnDialog: React.FC = ({
onChange={e => setProvider(e.target.value)}
placeholder="Amazon Web Services"
description="Human-readable provider name, e.g. Cloudflare or DigitalOcean."
+ maxLength={PROVIDER_NAME_MAX}
required
/>
@@ -315,6 +324,7 @@ const BlockedAsnDialog: React.FC = ({
onChange={e => setAsn(e.target.value)}
placeholder="AS16509"
description="Optional if CIDR ranges are provided."
+ maxLength={ASN_MAX}
/>
@@ -351,6 +361,7 @@ const BlockedAsnDialog: React.FC = ({
onChange={e => setReason(e.target.value)}
placeholder="AWS data-center range"
rows={4}
+ maxLength={BLOCK_REASON_MAX}
/>
@@ -461,6 +472,7 @@ const ResolveFlaggedTokenDialog: React.FC = ({
onChange={e => setNote(e.target.value)}
placeholder="Investigated automated scanner. Token owner notified and key rotated."
rows={5}
+ maxLength={RESOLUTION_NOTE_MAX}
/>
@@ -712,6 +724,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}`}
>
@@ -721,6 +734,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}`}
>
@@ -833,6 +847,7 @@ const SecurityPageContent: React.FC = () => {
size="sm"
variant="outlined"
onClick={() => handleResolveToken(item)}
+ title="Resolve flagged token"
>
Resolve
@@ -951,6 +966,7 @@ const SecurityPageContent: React.FC = () => {
variant="outlined"
Icon={AqRefreshCw05}
onClick={handleRefreshBlocked}
+ loading={blockedLoading}
className="ml-auto"
>
Refresh
@@ -1001,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/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/surveys/components/SurveyForm.tsx b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyForm.tsx
index 2fb81ae037..a48c9a928a 100644
--- a/src/platform/src/app/(dashboard)/system/surveys/components/SurveyForm.tsx
+++ b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyForm.tsx
@@ -18,6 +18,13 @@ import {
toast,
} from '@/shared/components/ui';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import {
+ SURVEY_TITLE_MAX,
+ SURVEY_DESCRIPTION_MAX,
+ SURVEY_QUESTION_MAX,
+ SURVEY_OPTION_MAX,
+ SURVEY_PLACEHOLDER_MAX,
+} from '@/shared/lib/validation-limits';
import {
createQuestionDraft,
createTriggerDraft,
@@ -338,6 +345,7 @@ const SurveyForm: React.FC = ({
placeholder="Air Quality Exposure Assessment"
required
error={titleError}
+ maxLength={SURVEY_TITLE_MAX}
/>
= ({
onChange={e => setDescription(e.target.value)}
placeholder="Help us understand how air quality affects your daily activities and health."
rows={4}
+ maxLength={SURVEY_DESCRIPTION_MAX}
/>
@@ -545,6 +554,7 @@ const SurveyForm: React.FC = ({
placeholder="What activity were you doing when you received this survey?"
rows={3}
required
+ maxLength={SURVEY_QUESTION_MAX}
/>
@@ -639,6 +649,7 @@ const SurveyForm: React.FC = ({
}
placeholder="Enter your answer here..."
description="Shown inside the response field."
+ maxLength={SURVEY_PLACEHOLDER_MAX}
/>
)}
@@ -657,6 +668,7 @@ const SurveyForm: React.FC = ({
}
placeholder={`Option one\nOption two\nOption three`}
rows={4}
+ maxLength={SURVEY_OPTION_MAX}
/>
Enter one option per line.
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 55b84efc35..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 {
@@ -31,6 +37,7 @@ import {
isForbiddenError,
} from '@/shared/utils/errorMessages';
import { AccessDenied } from '@/shared/components/AccessDenied';
+import { SEARCH_TERM_MAX } from '@/shared/lib/validation-limits';
import { toast } from '@/shared/components/ui/toast';
import { refreshWithToast } from '@/shared/utils/refreshWithToast';
@@ -39,9 +46,7 @@ const DetailItem: React.FC<{ label: string; value: string }> = ({
value,
}) => (
-
- {label}
-
+
{label}
{value}
);
@@ -338,10 +343,7 @@ const TeamMemberDetailContent: React.FC<{ memberId: string }> = ({
: 'Never'
}
/>
-
+
= ({
Role groups loaded
-
- {availableRoles.length}
-
+ {availableRoles.length}
{rolesError ? (
@@ -394,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
@@ -586,6 +590,7 @@ const TeamMemberDetailContent: React.FC<{ memberId: string }> = ({
}}
placeholder="Search and select a role..."
disabled={rolesLoading}
+ 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"
/>
@@ -612,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 6fc19684ce..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';
@@ -14,8 +19,9 @@ 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 TeamMembersContent: React.FC = () => {
+const MembersContent: React.FC = () => {
const router = useRouter();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -33,29 +39,21 @@ const TeamMembersContent: 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]);
@@ -105,11 +103,11 @@ const TeamMembersContent: React.FC = () => {
- router.push(`/system/team-members/${member._id}`)
- }
+ onClick={() => router.push(`/system/team-members/${member._id}`)}
+ title="View member details"
+ aria-label="View member details"
>
- View
+
),
},
@@ -118,12 +116,7 @@ const TeamMembersContent: React.FC = () => {
);
if (isLoading) {
- return (
-
- );
+ return ;
}
if (error) {
@@ -131,7 +124,7 @@ const TeamMembersContent: React.FC = () => {
return (
);
}
@@ -148,8 +141,8 @@ const TeamMembersContent: React.FC = () => {
{selectedMembers.length > 0 && (
{
({
...member,
id: member._id,
@@ -202,16 +194,16 @@ const TeamMembersContent: React.FC = () => {
);
};
-const TeamMembersPage: React.FC = () => {
+const MembersPage: React.FC = () => {
return (
-
+
);
};
-export default TeamMembersPage;
+export default MembersPage;
diff --git a/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx b/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx
index 3ac056baf7..64e548606b 100644
--- a/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx
@@ -1,550 +1,12 @@
-'use client';
-
-import React, { useMemo, useState, useCallback } from 'react';
-import { useParams, useRouter } from 'next/navigation';
-import {
- Button,
- Card,
- Dialog,
- ErrorBanner,
- LoadingState,
- PageHeading,
- Select,
- toast,
-} from '@/shared/components/ui';
-import { PermissionGuard } from '@/shared/components';
-import {
- useRolesSummary,
- useUpdateUserRole,
- useUserDetails,
-} from '@/shared/hooks';
-import {
- AqChevronLeft,
- AqRefreshCw05,
- AqShield02,
- AqUsers01,
- AqUsersCheck,
-} from '@airqo/icons-react';
-import { formatWithPattern } from '@/shared/utils/dateUtils';
-import {
- getUserFriendlyErrorMessage,
- isForbiddenError,
-} from '@/shared/utils/errorMessages';
-import { AccessDenied } from '@/shared/components/AccessDenied';
-import { refreshWithToast } from '@/shared/utils/refreshWithToast';
-
-const UserStatisticsDetailsPage: React.FC = () => {
- 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/user-statistics');
- }, [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 ? (
-
-
- Back to User Statistics
-
-
-
-
-
- ) : (
-
-
-
- Back
-
-
-
-
- Refresh
-
- }
- />
-
-
-
-
-
-
User Information
-
- Basic profile and account information
-
-
-
-
- {user.isActive ? 'Active' : 'Inactive'}
-
-
- {user.verified ? 'Verified' : 'Unverified'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {user.description && (
-
-
- Description
-
-
{user.description}
-
- )}
-
-
-
-
-
-
Role Assignment
-
- Update the selected role for this user
-
-
-
-
-
-
-
-
- Role groups loaded
-
-
- {availableRoles.length}
-
-
-
- {rolesError ? (
-
- ) : (
-
- Change Role
-
- )}
-
-
-
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'}
-
-
-
-
- ))
- ) : (
-
- 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',
- }}
- >
-
-
-
- Select Role *
-
-
setRoleSearch(e.target.value)}
- placeholder="Search roles or groups..."
- className="w-full mb-2 px-3 py-2 border rounded-md text-sm"
- />
-
- setSelectedRoleId(
- ((e as React.ChangeEvent)?.target
- ?.value as string) || ''
- )
- }
- placeholder="Choose a role..."
- disabled={rolesLoading}
- >
- {filteredRoles.map(role => (
-
- {role.role_name} - {role.group?.grp_title || 'No group'}
-
- ))}
-
- {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 (
-
-
- {label}
-
-
{value}
-
- );
-};
-
-export default UserStatisticsDetailsPage;
+import { redirect } from 'next/navigation';
+
+interface UserDetailRedirectPageProps {
+ params: { id: string };
+}
+
+export default function UserDetailRedirectPage({
+ params,
+}: UserDetailRedirectPageProps) {
+ const { id } = 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..b79ff744bb 100644
--- a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx
+++ b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx
@@ -1,336 +1,190 @@
'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 { useUserStatistics, useUsers } from '@/shared/hooks/useAdmin';
+import { formatWithPattern } from '@/shared/utils/dateUtils';
+import { ChartContainer, StatsPieChart } from '@/shared/components/charts';
+import { getPrimaryColor } from '@/shared/components/charts/constants';
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/shared/components/ui';
-import BulkRoleAssignmentDialog from '@/shared/components/BulkRoleAssignmentDialog';
-
-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;
+ AqUsers01,
+ AqUsersCheck,
+ AqKey01,
+ AqMail01,
+ AqRefreshCw05,
+ AqArrowRight,
+} from '@airqo/icons-react';
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ LineChart,
+ 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)' },
+};
- // 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]);
+const AXIS_LABEL_STYLE = {
+ textAnchor: 'start' as const,
+ fontSize: 12,
+ fill: 'rgb(100, 116, 139)',
+};
- // 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 TOOLTIP_STYLE = {
+ backgroundColor: 'hsl(var(--card))',
+ border: '1px solid hsl(var(--border))',
+ borderRadius: '8px',
+ fontSize: '12px',
+ color: 'hsl(var(--card-foreground))',
+};
- const paginatedData = useMemo(() => {
- const start = (page - 1) * pageSize;
- const end = start + pageSize;
- return filteredData.slice(start, end);
- }, [filteredData, page, pageSize]);
+interface ChartDataPoint {
+ name: string;
+ value: number;
+ [key: string]: string | number;
+}
- const totalPages = Math.ceil(filteredData.length / pageSize);
+const UserStatisticsPage: React.FC = () => {
+ const {
+ data: statsResponse,
+ isLoading: statsLoading,
+ error: statsError,
+ mutate: mutateStats,
+ } = useUserStatistics();
+
+ const {
+ data: usersResponse,
+ isLoading: usersLoading,
+ error: usersError,
+ mutate: mutateUsers,
+ } = useUsers();
+
+ const isLoading = statsLoading || usersLoading;
+ const error = statsError || usersError;
+
+ const stats = useMemo(() => {
+ const s = statsResponse?.users_stats;
+ return {
+ total: s?.users?.number ?? 0,
+ active: s?.active_users?.number ?? 0,
+ apiUsers: s?.api_users?.number ?? 0,
+ };
+ }, [statsResponse]);
- // Reset page to 1 when search changes
- useEffect(() => {
- setPage(1);
- setSelectedUsers([]);
- }, [search]);
+ const users = useMemo(() => usersResponse?.users ?? [], [usersResponse]);
- const handleViewDetails = useCallback(
- (userId: string) => {
- router.push(`/system/user-statistics/${userId}`);
- },
- [router]
+ const verifiedCount = useMemo(
+ () => users.filter(u => u.verified).length,
+ [users]
);
- // Table columns
- const columns = useMemo(
+ const statusData: 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: 'Active', value: stats.active },
+ { name: 'Inactive', value: stats.total - stats.active },
],
- [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.
- });
+ const verificationData: ChartDataPoint[] = useMemo(
+ () => [
+ { name: 'Verified', value: verifiedCount },
+ { name: 'Unverified', value: stats.total - verifiedCount },
+ ],
+ [stats, verifiedCount]
+ );
- // 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 {
await refreshWithToast(
- () => mutate(),
+ () => Promise.all([mutateStats(), mutateUsers()]),
'User statistics refreshed successfully'
);
- } catch (error) {
+ } catch (err) {
toast.error(
- error instanceof Error
- ? error.message
- : 'Unable to refresh user statistics'
+ err instanceof Error ? err.message : 'Unable to refresh user statistics'
);
}
- }, [mutate]);
+ }, [mutateStats, mutateUsers]);
if (isLoading) {
return (
@@ -351,17 +205,18 @@ const UserStatisticsPage: React.FC = () => {
);
}
return (
-
+
-
Retry
-
+
);
}
@@ -370,121 +225,291 @@ const UserStatisticsPage: React.FC = () => {
+
+ Refresh
+
+
+ Manage Users
+
+
+ }
/>
{/* 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
+
{verifiedCount}
+
+
+
+
+
+
+
+
API Users
+
{stats.apiUsers}
+
+
+
+
-
-
+
- Refresh
-
-
- {selectedUsers.length > 0 && (
- setShowBulkRoleDialog(true)}
- variant="outlined"
- showTextOnMobile
- >
- Assign Role ({selectedUsers.length})
-
+
+
+
+
+
+
+
+
+ {organizationData.length > 0 ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
)}
-
- Export CSV
-
-
- Export PDF
-
-
-
-
{
- 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();
- }}
- />
+
+ {loginRangesData.some(d => d.value > 0) ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {signupsOverTimeData.length > 0 ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {topGroupsData.length > 0 ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
);
};
-// Wrap with permission guard
+const NoDataState: React.FC = () => (
+
+);
+
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 || '';
+ }, [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);
+ setRoleSearch('');
+ 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 ? (
+
+
+ Back to User Management
+
+
+
+
+
+ ) : (
+
+
+
+ Back
+
+
+
+
+ Refresh
+
+ }
+ />
+
+
+
+
+
+
User Information
+
+ Basic profile and account information
+
+
+
+
+ {user.isActive ? 'Active' : 'Inactive'}
+
+
+ {user.verified ? 'Verified' : 'Unverified'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {user.description && (
+
+
+ Description
+
+
{user.description}
+
+ )}
+
+
+
+
+
+
Role Assignment
+
+ Update the selected role for this user
+
+
+
+
+
+
+
+
+ Role groups loaded
+
+
+ {availableRoles.length}
+
+
+
+ {rolesError ? (
+
+ ) : (
+
+ Change Role
+
+ )}
+
+
+
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'}
+
+
+
+
+ ))
+ ) : (
+
+ 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',
+ }}
+ >
+
+
+
+ Select Role *
+
+
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"
+ />
+
+ setSelectedRoleId(
+ ((e as React.ChangeEvent)?.target
+ ?.value as string) || ''
+ )
+ }
+ placeholder="Choose a role..."
+ disabled={rolesLoading}
+ >
+ {filteredRoles.map(role => (
+
+ {role.role_name} - {role.group?.grp_title || 'No group'}
+
+ ))}
+
+ {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 (
+
+
+ {label}
+
+
{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..55e767802c
--- /dev/null
+++ b/src/platform/src/app/(dashboard)/system/users/page.tsx
@@ -0,0 +1,541 @@
+'use client';
+
+import React, { useMemo, useState, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import {
+ Button,
+ 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,
+} 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 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 || '';
+ setRoleDialogUser(user);
+ setSelectedRoleId(primaryRoleId);
+ setRoleSearch('');
+ }, []);
+
+ 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.push(`/system/users/${user._id}`)}
+ title="View user details"
+ aria-label="View user details"
+ >
+
+
+
openRoleDialog(user)}
+ disabled={rolesLoading || availableRoles.length === 0}
+ title="Update user role"
+ aria-label="Update user role"
+ >
+
+
+
+ ),
+ },
+ ],
+ [router, openRoleDialog, rolesLoading, availableRoles.length]
+ );
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ if (isForbiddenError(error)) {
+ return (
+
+ );
+ }
+ return (
+
+
+
+ Retry
+
+
+ );
+ }
+
+ return (
+
+
+
+ Refresh
+
+
+ Export CSV
+
+
+ Export PDF
+
+
+ }
+ />
+
+ {/* 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',
+ }}
+ >
+
+
+
+ Select Role *
+
+ setRoleSearch(e.target.value)}
+ placeholder="Search roles or groups..."
+ maxLength={SEARCH_TERM_MAX}
+ containerClassName="mb-2"
+ />
+
+ setSelectedRoleId(
+ (e as React.ChangeEvent).target.value
+ )
+ }
+ placeholder="Choose a role..."
+ disabled={rolesLoading}
+ >
+ {filteredRoles.map(role => (
+
+ {role.role_name} - {role.group?.grp_title || 'No group'}
+
+ ))}
+
+
+
+ {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({ 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}
/>
= ({
})
}
containerClassName="mb-0"
+ maxLength={CHART_AXIS_LABEL_MAX}
/>
= ({
})
}
containerClassName="mb-0"
+ maxLength={CHART_AXIS_LABEL_MAX}
/>
= ({
})
}
containerClassName="mb-0"
+ maxLength={CHART_REFERENCE_LABEL_MAX}
/>
diff --git a/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx b/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx
index 847d514625..819d610298 100644
--- a/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx
+++ b/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx
@@ -11,6 +11,7 @@ import { Button, Select, TextInput } from '@/shared/components/ui';
import ReusableDialog from '@/shared/components/ui/dialog';
import { toast } from '@/shared/components/ui/toast';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
+import { FEEDBACK_MESSAGE_MAX } from '@/shared/lib/validation-limits';
import {
MAX_IMAGE_FILE_SIZE_BYTES,
PROFILE_IMAGE_ALLOWED_MIME_TYPES,
@@ -928,6 +929,7 @@ export const FeedbackLauncher: React.FC = () => {
rows={6}
required
containerClassName="md:col-span-2"
+ maxLength={FEEDBACK_MESSAGE_MAX}
/>
diff --git a/src/platform/src/modules/location-insights/more-insights.tsx b/src/platform/src/modules/location-insights/more-insights.tsx
index d9f5587185..f1e36edb41 100644
--- a/src/platform/src/modules/location-insights/more-insights.tsx
+++ b/src/platform/src/modules/location-insights/more-insights.tsx
@@ -17,6 +17,7 @@ import { POLLUTANT_LABELS } from '@/shared/components/charts/constants';
import type { ChartData } from '@/modules/analytics/types';
import { normalizeAirQualityData } from '@/shared/components/charts/utils';
import type { ChartDataPoint } from '@/shared/types/api';
+import { SEARCH_TERM_MAX } from '@/shared/lib/validation-limits';
// Pollutant options
const POLLUTANT_OPTIONS = Object.entries(POLLUTANT_LABELS).map(
@@ -513,6 +514,7 @@ export const MoreInsights: React.FC
= ({ activeTab }) => {
placeholder="Search locations..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
+ maxLength={SEARCH_TERM_MAX}
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-md bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
{searchQuery && (
diff --git a/src/platform/src/modules/organization/components/GroupDetailsSettings.tsx b/src/platform/src/modules/organization/components/GroupDetailsSettings.tsx
index 95aa78b46a..68969aaa69 100644
--- a/src/platform/src/modules/organization/components/GroupDetailsSettings.tsx
+++ b/src/platform/src/modules/organization/components/GroupDetailsSettings.tsx
@@ -22,6 +22,13 @@ import SettingsLayout from '@/modules/user-profile/components/SettingsLayout';
import { toast } from '@/shared/components/ui/toast';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
import { GroupDetails, UpdateGroupDetailsRequest } from '@/shared/types/api';
+import {
+ GROUP_TITLE_MAX,
+ GROUP_DESCRIPTION_MAX,
+ GROUP_INDUSTRY_MAX,
+ GROUP_TIMEZONE_MAX,
+ GROUP_WEBSITE_MAX,
+} from '@/shared/lib/validation-limits';
import { AqCheck, AqUploadCloud01 } from '@airqo/icons-react';
import { setActiveGroup, setGroups } from '@/shared/store/userSlice';
import { normalizeGroupDetails } from '@/shared/utils/userUtils';
@@ -318,6 +325,7 @@ const GroupDetailsSettings: React.FC = () => {
name="grp_title"
value={groupTitle}
onChange={e => setGroupTitle(e.target.value)}
+ maxLength={GROUP_TITLE_MAX}
required
/>
@@ -443,6 +451,7 @@ const GroupDetailsSettings: React.FC = () => {
name="grp_industry"
value={formData.grp_industry || ''}
onChange={handleInputChange}
+ maxLength={GROUP_INDUSTRY_MAX}
/>
{/* Country */}
@@ -468,6 +477,7 @@ const GroupDetailsSettings: React.FC = () => {
name="grp_timezone"
value={formData.grp_timezone || ''}
onChange={handleInputChange}
+ maxLength={GROUP_TIMEZONE_MAX}
/>
{/* Website */}
@@ -478,6 +488,7 @@ const GroupDetailsSettings: React.FC = () => {
name="grp_website"
value={formData.grp_website || ''}
onChange={handleInputChange}
+ maxLength={GROUP_WEBSITE_MAX}
/>
@@ -489,6 +500,7 @@ const GroupDetailsSettings: React.FC = () => {
rows={4}
value={formData.grp_description || ''}
onChange={handleInputChange}
+ maxLength={GROUP_DESCRIPTION_MAX}
/>
diff --git a/src/platform/src/modules/organization/components/ThemeSettings.tsx b/src/platform/src/modules/organization/components/ThemeSettings.tsx
index 4299be95a2..3b2e8bdd81 100644
--- a/src/platform/src/modules/organization/components/ThemeSettings.tsx
+++ b/src/platform/src/modules/organization/components/ThemeSettings.tsx
@@ -20,6 +20,7 @@ import ReusableDialog from '@/shared/components/ui/dialog';
import { Button } from '@/shared/components/ui/button';
import { HexColorPicker } from 'react-colorful';
import { useUser } from '@/shared/hooks/useUser';
+import { HEX_COLOR_MAX } from '@/shared/lib/validation-limits';
import {
useGroupTheme,
useUpdateOrganizationGroupTheme,
@@ -309,6 +310,7 @@ const ThemeSettings: React.FC = () => {
type="text"
value={customColor}
onChange={e => setCustomColor(e.target.value)}
+ maxLength={HEX_COLOR_MAX}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800"
/>
diff --git a/src/platform/src/modules/themes/components/ThemeManager.tsx b/src/platform/src/modules/themes/components/ThemeManager.tsx
index 55ff3a5421..6ae22cfccf 100644
--- a/src/platform/src/modules/themes/components/ThemeManager.tsx
+++ b/src/platform/src/modules/themes/components/ThemeManager.tsx
@@ -17,6 +17,7 @@ import { HexColorPicker } from 'react-colorful';
import { useUser } from '@/shared/hooks/useUser';
import { useGroupTheme } from '@/shared/hooks/usePreferences';
import { Tooltip } from 'flowbite-react';
+import { HEX_COLOR_MAX } from '@/shared/lib/validation-limits';
import SettingsLayout from '@/modules/user-profile/components/SettingsLayout';
const presetColors = [
@@ -213,6 +214,7 @@ const ThemeManager: React.FC = () => {
type="text"
value={customColor}
onChange={e => setCustomColor(e.target.value)}
+ maxLength={HEX_COLOR_MAX}
className="flex-1 px-2 py-1 text-sm border border-border rounded-md bg-background"
/>
diff --git a/src/platform/src/modules/user-profile/components/ProfileForm.tsx b/src/platform/src/modules/user-profile/components/ProfileForm.tsx
index d4a4eaa4fb..df848c35c0 100644
--- a/src/platform/src/modules/user-profile/components/ProfileForm.tsx
+++ b/src/platform/src/modules/user-profile/components/ProfileForm.tsx
@@ -25,6 +25,12 @@ import {
MAX_IMAGE_FILE_SIZE_BYTES,
} from '@/shared/utils/cloudinaryUpload';
import { profileSchema, type ProfileFormData } from '@/shared/lib/validators';
+import {
+ FIRST_NAME_MAX,
+ LAST_NAME_MAX,
+ JOB_TITLE_MAX,
+ USER_BIO_MAX,
+} from '@/shared/lib/validation-limits';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
import { ProfileFormData as ProfileFormType } from '../types';
import SettingsLayout from './SettingsLayout';
@@ -328,6 +334,7 @@ const ProfileForm: React.FC = ({ userId }) => {
type="text"
placeholder="Enter your first name"
error={errors.firstName?.message}
+ maxLength={FIRST_NAME_MAX}
required
/>
= ({ 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..3c46cebfec 100644
--- a/src/platform/src/shared/components/BulkRoleAssignmentDialog.tsx
+++ b/src/platform/src/shared/components/BulkRoleAssignmentDialog.tsx
@@ -1,11 +1,18 @@
'use client';
-import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
+import React, {
+ useState,
+ useMemo,
+ useRef,
+ useEffect,
+ useCallback,
+} from 'react';
import Dialog from '@/shared/components/ui/dialog';
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;
@@ -94,9 +101,9 @@ const BulkRoleAssignmentDialog: React.FC = ({
useEffect(() => {
if (isDropdownOpen && highlightedIndex >= 0 && listRef.current) {
- const highlightedElement = listRef.current.children[
- highlightedIndex
- ] as HTMLElement | undefined;
+ const highlightedElement = listRef.current.children[highlightedIndex] as
+ | HTMLElement
+ | undefined;
highlightedElement?.scrollIntoView({ block: 'nearest' });
}
}, [isDropdownOpen, highlightedIndex]);
@@ -209,6 +216,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"
/>
@@ -234,9 +242,7 @@ const BulkRoleAssignmentDialog: React.FC = ({
selectedRoleId === role._id
? 'bg-primary/20 text-primary font-medium'
: 'text-foreground'
- } ${
- highlightedIndex === index ? 'bg-primary/10' : ''
- }`}
+ } ${highlightedIndex === index ? 'bg-primary/10' : ''}`}
>
{role.role_name} - {role.group?.grp_title || 'No group'}
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/charts/components/ui/StatsPieChart.tsx b/src/platform/src/shared/components/charts/components/ui/StatsPieChart.tsx
new file mode 100644
index 0000000000..a8a77ff319
--- /dev/null
+++ b/src/platform/src/shared/components/charts/components/ui/StatsPieChart.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import React from 'react';
+import {
+ PieChart,
+ Pie,
+ Cell,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+} from 'recharts';
+import type { PieLabelRenderProps } from 'recharts';
+import { getPrimaryColor } from '../../constants';
+
+export interface StatsPieChartDataPoint {
+ name: string;
+ value: number;
+}
+
+interface StatsPieChartProps {
+ data: StatsPieChartDataPoint[];
+ height?: number;
+ innerRadius?: number;
+ outerRadius?: number;
+ showLabel?: boolean;
+ showLegend?: boolean;
+ colors?: string[];
+}
+
+const StatsPieChart: React.FC = ({
+ data,
+ height = 300,
+ innerRadius = 0,
+ outerRadius = 100,
+ showLabel = true,
+ showLegend = true,
+ colors,
+}) => {
+ const total = data?.reduce((sum, d) => sum + (d.value || 0), 0) ?? 0;
+
+ if (!data || data.length === 0 || total === 0) {
+ return (
+
+ );
+ }
+
+ 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..090b514763 100644
--- a/src/platform/src/shared/components/charts/index.ts
+++ b/src/platform/src/shared/components/charts/index.ts
@@ -12,6 +12,11 @@ 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';
diff --git a/src/platform/src/shared/components/header/components/info-dropdown.tsx b/src/platform/src/shared/components/header/components/info-dropdown.tsx
index 055c8e9dac..c0eddd64b3 100644
--- a/src/platform/src/shared/components/header/components/info-dropdown.tsx
+++ b/src/platform/src/shared/components/header/components/info-dropdown.tsx
@@ -39,9 +39,7 @@ const InfoDropdown: React.FC = ({ className = '' }) => {
Share Feedback
- window.open(DOCS_URL, '_blank', 'noopener,noreferrer')
- }
+ onClick={() => window.open(DOCS_URL, '_blank', 'noopener,noreferrer')}
>
Learn about AirQo Analytics
diff --git a/src/platform/src/shared/components/sidebar/config/index.ts b/src/platform/src/shared/components/sidebar/config/index.ts
index 18e95a38f9..68b7fad997 100644
--- a/src/platform/src/shared/components/sidebar/config/index.ts
+++ b/src/platform/src/shared/components/sidebar/config/index.ts
@@ -254,7 +254,7 @@ const systemSidebarConfig: NavGroup[] = [
},
{
id: 'system-team-members',
- label: 'Team Members',
+ label: 'Members',
href: '/system/team-members',
icon: AqUsers01,
},
@@ -294,6 +294,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',
@@ -397,11 +403,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/input.tsx b/src/platform/src/shared/components/ui/input.tsx
index fdc7957ff3..bf3d157dec 100644
--- a/src/platform/src/shared/components/ui/input.tsx
+++ b/src/platform/src/shared/components/ui/input.tsx
@@ -1,4 +1,3 @@
-// Clean, tested forwardRef Input component
import * as React from 'react';
import { AqEye, AqEyeOff, AqAlertCircle } from '@airqo/icons-react';
@@ -35,12 +34,35 @@ const Input = React.forwardRef(
description,
showPasswordToggle = false,
onChange,
+ maxLength,
+ value,
+ defaultValue,
...inputProps
} = props;
const [showPassword, setShowPassword] = React.useState(false);
+ const maxLengthNum = typeof maxLength === 'number' ? maxLength : undefined;
+
+ const getInitialLength = (): number => {
+ if (value !== undefined && value !== null) return String(value).length;
+ if (defaultValue !== undefined && defaultValue !== null)
+ return String(defaultValue).length;
+ return 0;
+ };
+
+ const [charCount, setCharCount] = React.useState(getInitialLength);
+
+ React.useEffect(() => {
+ if (value !== undefined && value !== null) {
+ setCharCount(String(value).length);
+ }
+ }, [value]);
+
const handleChange = (e: React.ChangeEvent) => {
+ if (maxLengthNum !== undefined) {
+ setCharCount(e.target.value.length);
+ }
if (!onChange) return;
try {
onChange(e);
@@ -57,17 +79,19 @@ const Input = React.forwardRef(
}
};
+ const isOverLimit = maxLengthNum !== undefined && charCount > maxLengthNum;
+ const maxLengthError = isOverLimit
+ ? `Max ${maxLengthNum} characters allowed`
+ : undefined;
+
+ const displayError = error || maxLengthError;
+
return (
{label && (
{label}
- {required &&
- (primaryColor ? (
- *
- ) : (
- *
- ))}
+ {required && * }
)}
@@ -98,18 +122,21 @@ const Input = React.forwardRef(
style={
primaryColor
? {
- borderColor: error ? 'red' : primaryColor,
- boxShadow: error
+ borderColor: displayError ? 'red' : primaryColor,
+ boxShadow: displayError
? '0 0 0 1px red'
: `0 0 0 1px ${primaryColor}50`,
}
- : error
+ : displayError
? { borderColor: 'red', boxShadow: '0 0 0 1px red' }
: undefined
}
disabled={disabled}
required={required}
+ maxLength={maxLength}
onChange={handleChange}
+ value={value}
+ defaultValue={defaultValue}
{...inputProps}
/>
@@ -129,18 +156,30 @@ const Input = React.forwardRef(
)}
- {error && (
+ {displayError && (
- {error}
+ {displayError}
)}
- {!error && description && (
+ {!displayError && description && (
{description}
)}
+
+ {!displayError && maxLengthNum !== undefined && charCount > 0 && (
+ = maxLengthNum
+ ? 'text-destructive font-medium'
+ : 'text-muted-foreground'
+ }`}
+ >
+ {charCount}/{maxLengthNum}
+
+ )}
);
}
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(
({
(
required = false,
disabled = false,
rows = 4,
+ maxLength,
+ value,
+ defaultValue,
+ onChange,
...rest
},
ref
) => {
+ const maxLengthNum = typeof maxLength === 'number' ? maxLength : undefined;
+
+ const getInitialLength = (): number => {
+ if (value !== undefined && value !== null) return String(value).length;
+ if (defaultValue !== undefined && defaultValue !== null)
+ return String(defaultValue).length;
+ return 0;
+ };
+
+ const [charCount, setCharCount] = React.useState(getInitialLength);
+
+ React.useEffect(() => {
+ if (value !== undefined && value !== null) {
+ setCharCount(String(value).length);
+ }
+ }, [value]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ if (maxLengthNum !== undefined) {
+ setCharCount(e.target.value.length);
+ }
+ if (onChange) {
+ onChange(e);
+ }
+ };
+
+ const isOverLimit = maxLengthNum !== undefined && charCount > maxLengthNum;
+ const maxLengthError = isOverLimit
+ ? `Max ${maxLengthNum} characters allowed`
+ : undefined;
+
+ const displayError = error || maxLengthError;
+
return (
{label && (
@@ -57,6 +94,10 @@ const TextInput = React.forwardRef(
disabled={disabled}
required={required}
rows={rows}
+ maxLength={maxLength}
+ value={value}
+ defaultValue={defaultValue}
+ onChange={handleChange}
className={`
w-full px-4 py-2.5 rounded-md border bg-background outline-none text-sm
text-foreground placeholder-muted-foreground
@@ -68,6 +109,7 @@ const TextInput = React.forwardRef(
focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none
disabled:cursor-not-allowed disabled:border-input disabled:bg-muted disabled:text-muted-foreground
+ ${displayError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}
${inputClassName}
`}
style={{
@@ -79,7 +121,7 @@ const TextInput = React.forwardRef(
/>
- {error && (
+ {displayError && (
(
clipRule="evenodd"
/>
- {error}
+ {displayError}
+
+ )}
+
+ {!displayError && maxLengthNum !== undefined && charCount > 0 && (
+ = maxLengthNum
+ ? 'text-destructive font-medium'
+ : 'text-muted-foreground'
+ }`}
+ >
+ {charCount}/{maxLengthNum}
)}
diff --git a/src/platform/src/shared/hooks/useAdmin.ts b/src/platform/src/shared/hooks/useAdmin.ts
index 95fc40ff9d..aa71c8f589 100644
--- a/src/platform/src/shared/hooks/useAdmin.ts
+++ b/src/platform/src/shared/hooks/useAdmin.ts
@@ -239,6 +239,18 @@ export const useUserStatistics = () => {
);
};
+// 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(
@@ -266,6 +278,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/metadata.ts b/src/platform/src/shared/lib/metadata.ts
index 403c126307..fa0e4578c9 100644
--- a/src/platform/src/shared/lib/metadata.ts
+++ b/src/platform/src/shared/lib/metadata.ts
@@ -253,7 +253,8 @@ const pageMetadata: Record
> = {
description: 'View team member profile and manage their role assignments.',
openGraph: {
title: 'Team Member Details | AirQo Analytics',
- description: 'View team member profile and manage their role assignments.',
+ description:
+ 'View team member profile and manage their role assignments.',
},
},
};
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..58f9e31c20 100644
--- a/src/platform/src/shared/lib/validators.ts
+++ b/src/platform/src/shared/lib/validators.ts
@@ -1,17 +1,61 @@
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 +63,26 @@ 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 +94,14 @@ 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 +109,58 @@ 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 +172,45 @@ 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 +223,72 @@ 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 +314,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 2cdcde007b..d63ce18d94 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;