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} /> diff --git a/src/platform/src/app/(dashboard)/system/clients/page.tsx b/src/platform/src/app/(dashboard)/system/clients/page.tsx index 4c66b34b85..fc69859be9 100644 --- a/src/platform/src/app/(dashboard)/system/clients/page.tsx +++ b/src/platform/src/app/(dashboard)/system/clients/page.tsx @@ -86,6 +86,7 @@ const ClientsAdminPage: React.FC = () => { const { data: clientsResponse, isLoading, + isValidating, error, mutate, } = useSWR('/api/clients', () => clientService.getClients()); @@ -437,6 +438,7 @@ const ClientsAdminPage: React.FC = () => { variant="outlined" Icon={AqRefreshCw05} onClick={handleRefresh} + loading={isValidating} className="ml-auto" > Refresh @@ -559,7 +561,7 @@ const ClientsAdminPage: React.FC = () => { diff --git a/src/platform/src/app/(dashboard)/system/email-configs/page.tsx b/src/platform/src/app/(dashboard)/system/email-configs/page.tsx index 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} /> } @@ -403,7 +410,12 @@ const OrganizationRequestsPage = () => { {requests.filter(r => r.status === 'rejected').length}) - @@ -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, }) => (

- +

{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 = () => { ), }, @@ -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 ? ( -
- - - - -
- ) : ( -
-
- -
- - - Refresh - - } - /> - -
- -
-
-

User Information

-

- Basic profile and account information -

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

{user.description}

-
- )} -
- - -
-
-

Role Assignment

-

- Update the selected role for this user -

-
- -
- -
-
-

- Role groups loaded -

-

- {availableRoles.length} -

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

Permissions Overview

-

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

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

Current Access

-

- Roles inherited from networks and groups -

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

- Networks -

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

{network.net_name}

-

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

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

- No network roles assigned. -

- )} -
- -
-

- Groups -

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

{group.grp_title}

-

- {group.organization_slug} -

-

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

-
- )) - ) : ( -

- No group roles assigned. -

- )} -
-
-
- -
-

- Permissions -

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

- No direct permissions returned for this user. -

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

- Loading all selectable roles... -

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

Selected Role

-

- {selectedRole.role_name} -

-

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

-
- -
-

Permissions

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

{value}

-
- ); -}; - -export default UserStatisticsDetailsPage; +import { redirect } from 'next/navigation'; + +interface UserDetailRedirectPageProps { + params: { 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 ( -
+
- +
); } @@ -370,121 +225,291 @@ const UserStatisticsPage: React.FC = () => {
+ + + + +
+ } /> {/* Statistics Cards */} -
- {statsCards.map(card => { - const Icon = card.icon; - const isActive = activeTab === card.id; - return ( - { - setActiveTab(card.id as 'all' | 'active' | 'api'); - setPage(1); - setSearch(''); - setSelectedUsers([]); - }} - className={`cursor-pointer p-6 transition-all ${ - isActive ? 'border-primary bg-primary/5' : 'hover:shadow-md' - }`} - > -
-
-

- {card.title} -

-

{card.count}

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

Total Users

+

{stats.total}

+
+
+ +
+
+
+ +
+
+

Active Users

+

{stats.active}

+
+
+ +
+
+
+ +
+
+

Verified Users

+

{verifiedCount}

+
+
+ +
+
+
+ +
+
+

API Users

+

{stats.apiUsers}

+
+
+ +
+
+
-
- -
- {selectedUsers.length > 0 && ( - + + + + + + + + + {organizationData.length > 0 ? ( + + + + + + + + + + ) : ( + )} - - -
-
- { - 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 = () => ( +
+
+ +

No data available

+
+
+); + const ProtectedUserStatisticsPage: React.FC = () => { return ( { + const params = useParams(); + const router = useRouter(); + const userId = params.id as string; + + const { + data: userResponse, + isLoading: userLoading, + error: userError, + mutate: mutateUser, + } = useUserDetails(userId); + const { + data: rolesResponse, + isLoading: rolesLoading, + error: rolesError, + mutate: mutateRoles, + } = useRolesSummary(); + const updateUserRole = useUpdateUserRole(); + + const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false); + const [selectedRoleId, setSelectedRoleId] = useState(''); + const [roleSearch, setRoleSearch] = useState(''); + + const user = userResponse?.users?.[0]; + const availableRoles = useMemo( + () => rolesResponse?.roles || [], + [rolesResponse?.roles] + ); + + const filteredRoles = useMemo(() => { + const q = roleSearch.trim().toLowerCase(); + if (!q) return availableRoles; + return availableRoles.filter(r => { + const name = (r.role_name || '').toLowerCase(); + const group = (r.group?.grp_title || '').toLowerCase(); + return name.includes(q) || group.includes(q); + }); + }, [availableRoles, roleSearch]); + + const primaryRoleId = useMemo(() => { + return user?.networks?.[0]?.role?._id || user?.groups?.[0]?.role?._id || ''; + }, [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 ? ( +
+ + + + +
+ ) : ( +
+
+ +
+ + + Refresh + + } + /> + +
+ +
+
+

User Information

+

+ Basic profile and account information +

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

{user.description}

+
+ )} +
+ + +
+
+

Role Assignment

+

+ Update the selected role for this user +

+
+ +
+ +
+
+

+ Role groups loaded +

+

+ {availableRoles.length} +

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

Permissions Overview

+

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

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

Current Access

+

+ Roles inherited from networks and groups +

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

+ Networks +

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

{network.net_name}

+

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

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

+ No network roles assigned. +

+ )} +
+ +
+

+ Groups +

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

{group.grp_title}

+

+ {group.organization_slug} +

+

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

+
+ )) + ) : ( +

+ No group roles assigned. +

+ )} +
+
+
+ +
+

+ Permissions +

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

+ No direct permissions returned for this user. +

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

+ Loading all selectable roles... +

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

Selected Role

+

+ {selectedRole.role_name} +

+

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

+
+ +
+

Permissions

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

{value}

+
+ ); +}; + +export default UserDetailPage; diff --git a/src/platform/src/app/(dashboard)/system/users/page.tsx b/src/platform/src/app/(dashboard)/system/users/page.tsx new file mode 100644 index 0000000000..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, openRoleDialog, rolesLoading, availableRoles.length] + ); + + if (isLoading) { + return ( + + ); + } + + if (error) { + if (isForbiddenError(error)) { + return ( + + ); + } + return ( +
+ + +
+ ); + } + + return ( +
+ + + + +
+ } + /> + + {/* Users Table */} + { + setPageSize(size); + setPage(1); + }} + searchTerm={search} + onSearchChange={value => { + setSearch(value); + setPage(1); + }} + /> + + {/* Role Update Dialog */} + setRoleDialogUser(null)} + title={`Update Role: ${roleDialogUser?.firstName ?? ''} ${ + roleDialogUser?.lastName ?? '' + }`.trim()} + primaryAction={{ + label: 'Update Role', + onClick: handleUpdateRole, + disabled: updateUserRole.isMutating || !selectedRoleId, + loading: updateUserRole.isMutating, + }} + secondaryAction={{ + label: 'Cancel', + onClick: () => setRoleDialogUser(null), + disabled: updateUserRole.isMutating, + variant: 'outlined', + }} + > +
+
+ + setRoleSearch(e.target.value)} + placeholder="Search roles or groups..." + maxLength={SEARCH_TERM_MAX} + containerClassName="mb-2" + /> + +
+ + {selectedRole && ( +
+
+

Selected Role

+

+ {selectedRole.role_name} +

+

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

+
+
+

Permissions

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

+

No data available

+
+ ); + } + + return ( + + + + {showLegend && ( + + )} + { + const name = String(props.name ?? ''); + const percent = Number(props.percent ?? 0); + return `${name} (${(percent * 100).toFixed(0)}%)`; + } + : undefined + } + > + {data.map((_, index) => ( + + ))} + + + + ); +}; + +export { StatsPieChart }; +export type { StatsPieChartProps }; diff --git a/src/platform/src/shared/components/charts/index.ts b/src/platform/src/shared/components/charts/index.ts index d50659254a..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 && ( )} @@ -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;