diff --git a/src/vertex-template/.dockerignore b/src/vertex-template/.dockerignore new file mode 100644 index 0000000000..79578a8e7b --- /dev/null +++ b/src/vertex-template/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +node_modules +npm-debug.log* +.vscode +coverage \ No newline at end of file diff --git a/src/vertex-template/.env.example b/src/vertex-template/.env.example new file mode 100644 index 0000000000..0ae37dbaef --- /dev/null +++ b/src/vertex-template/.env.example @@ -0,0 +1,21 @@ +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000 + +NEXT_PUBLIC_SLACK_BOT_TOKEN= +NEXT_PUBLIC_SLACK_CHANNEL=notifs-airqo-netmanager-web +SLACK_WEBHOOK_URL= + +NEXT_PUBLIC_API_TOKEN= +NEXT_PUBLIC_API_BASE_URL=https://api.airqo.net +NEXT_PUBLIC_API_URL=https://staging-vertex.airqo.net/api/v2/ +NEXT_PUBLIC_ENV=development +NEXT_PUBLIC_ANALYTICS_URL=https://staging-analytics.airqo.net +NEXT_PUBLIC_VERTEX_DESKTOP_WINDOWS_DOWNLOAD_URL= +NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= +NEXT_PUBLIC_MOCK_PERMISSIONS_ENABLED=false +ADMIN_SECRET= +NEXT_PUBLIC_CLOUDINARY_NAME= +NEXT_PUBLIC_CLOUDINARY_PRESET= +NEXT_PUBLIC_HCAPTCHA_SITE_KEY= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= diff --git a/src/vertex-template/.eslintrc.json b/src/vertex-template/.eslintrc.json new file mode 100644 index 0000000000..3722418549 --- /dev/null +++ b/src/vertex-template/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/src/vertex-template/.gitignore b/src/vertex-template/.gitignore new file mode 100644 index 0000000000..7acd1072c3 --- /dev/null +++ b/src/vertex-template/.gitignore @@ -0,0 +1,125 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Removing the idea +.idea/ + + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.local +.env.development + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next/ + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Cypress files +cypress/videos +cypress/screenshots + +# Yarn add output +.pnp.cjs +.pnp.loader.mjs +.yarn/ +.yarnrc.yml + +# vscode files +.vscode/* + +.lighthouseci \ No newline at end of file diff --git a/src/vertex-template/.prettierignore b/src/vertex-template/.prettierignore new file mode 100644 index 0000000000..90e6bd2ed2 --- /dev/null +++ b/src/vertex-template/.prettierignore @@ -0,0 +1,11 @@ +node_modules +.next +dist +build +*.log +.DS_Store +*.lock +yarn.lock +package-lock.json +coverage +.nyc_output \ No newline at end of file diff --git a/src/vertex-template/.prettierrc b/src/vertex-template/.prettierrc new file mode 100644 index 0000000000..53b311509d --- /dev/null +++ b/src/vertex-template/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "avoid" +} diff --git a/src/vertex-template/Dockerfile b/src/vertex-template/Dockerfile new file mode 100644 index 0000000000..dec87e368e --- /dev/null +++ b/src/vertex-template/Dockerfile @@ -0,0 +1,41 @@ +# First stage: Build the app +FROM node:18-alpine AS builder + +# Set the working directory +WORKDIR /app + +# Copy package files and install dependencies +COPY package*.json ./ +RUN npm ci + + +# Copy the rest of the application code +COPY . . + +# Build the Next.js app +RUN npm run build + +# Second stage: Production container +FROM node:18-alpine AS runner + +# Set the working directory +WORKDIR /app + +# Copy built files from builder stage +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/public ./public +COPY --from=builder /app/start-next.js ./start-next.js + +# Add healthcheck +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + +USER node + +# Expose the application port +EXPOSE 3000 + +# Command to run the application +CMD ["npm", "run", "start"] diff --git a/src/vertex-template/README.md b/src/vertex-template/README.md new file mode 100644 index 0000000000..889af50aff --- /dev/null +++ b/src/vertex-template/README.md @@ -0,0 +1,92 @@ +# Vertex (Web App). + +`vertex` is the AirQo web application for Device and Network management. + +## Quick start + +1. Install dependencies: + +```bash +cd src/vertex +npm install +``` + +2. Create local environment file: + +```bash +copy .env.example .env +``` + +3. Run development server: + +```bash +npm run dev +``` + +4. Open `http://localhost:3000`. + +## Scripts + +- `npm run dev`: Start local development server. +- `npm run dev:inspect`: Start dev server with Node inspector enabled. +- `npm run build`: Build production bundle. +- `npm run start`: Start production server from built output. +- `npm run lint`: Run lint checks. +- `npm run format`: Format code with Prettier. +- `npm run format:check`: Check formatting. +- `npm run check-size`: Build and run bundle size checks. + +## Environment variables + +Use `src/vertex/.env.example` as the base. Common variables include: + +- `NEXT_PUBLIC_API_URL`: Backend API base URL. +- `NEXT_PUBLIC_API_BASE_URL`: Public API origin used for measurement URL examples. +- `NEXT_PUBLIC_ANALYTICS_URL`: Analytics platform URL. +- `NEXT_PUBLIC_VERTEX_DESKTOP_WINDOWS_DOWNLOAD_URL`: Optional Windows installer URL for Vertex Desktop downloads. +- `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`: Mapbox token for map features. +- `NEXT_PUBLIC_ENV`: App environment label (for example `development`). +- `NEXT_PUBLIC_MOCK_PERMISSIONS_ENABLED`: Enables mock permissions when needed. +- `ADMIN_SECRET`: Secret used by admin/protected server operations. +- `NEXT_PUBLIC_CLOUDINARY_NAME`: Cloudinary cloud name. +- `NEXT_PUBLIC_CLOUDINARY_PRESET`: Cloudinary upload preset. +- `NEXT_PUBLIC_HCAPTCHA_SITE_KEY`: HCaptcha site key needed for the new login flow. +- `SLACK_WEBHOOK_URL`: Slack webhook for server-side notifications. +- `NEXT_PUBLIC_SLACK_BOT_TOKEN`, `NEXT_PUBLIC_SLACK_CHANNEL`: Slack client configuration. + +## Vertex configuration + +Vertex has a typed app configuration surface in `vertex.config.ts`. This is the file that the future `create-vertex-app` CLI will generate or update for each scaffolded instance. + +V1 supports two data adapters: + +- `mock`: runs locally without API credentials and is the default for generated templates. +- `airqo`: uses the existing AirQo API, auth, and proxy behavior. + +Generic REST backends are intentionally out of scope for v1. + +Use `vertex.config.example.ts` as the template-facing reference. Keep validation and shared types in `core/config/vertex-config.ts` so contributors can add config-driven behavior without inventing new config shapes. + +## Authentication and SSO + +For normal app-local authentication, set: + +```bash +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3001 +``` + +Notes: + +- `NEXTAUTH_SECRET` should be unique to Vertex. +- `NEXTAUTH_URL` should match the local or production origin for Vertex. + +## Related projects + +- `src/vertex-desktop`: Electron wrapper that loads the hosted Vertex web app. +- Most feature and business logic remains in this web app; desktop-specific behavior stays in `src/vertex-desktop`. + +## Deployment notes + +- Staging and production deployment automation is configured in `.github/workflows/`. +- This app is deployed independently from desktop installer releases. diff --git a/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx new file mode 100644 index 0000000000..f9ee003f32 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useParams } from "next/navigation"; +import DeviceDetailsLayout from "@/components/features/devices/device-details-layout"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function AdminCohortDeviceDetailsPage() { + const params = useParams(); + const deviceId = params?.deviceId; + + if (!deviceId || typeof deviceId !== 'string') { + return ( +
+

Invalid device ID

+
+ ); + } + + return ( + + + + ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx new file mode 100644 index 0000000000..29b204c19e --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { AqArrowLeft, AqMinusCircle, AqPlus } from "@airqo/icons-react"; +import { AssignCohortDevicesDialog } from "@/components/features/cohorts/assign-cohort-devices"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { useCohortDetails } from "@/core/hooks/useCohorts"; +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; +import CohortDetailsCard from "@/components/features/cohorts/cohort-detail-card"; +import CohortDetailsModal from "@/components/features/cohorts/edit-cohort-details-modal"; +import { usePermission } from "@/core/hooks/usePermissions"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { UnassignCohortDevicesDialog } from "@/components/features/cohorts/unassign-cohort-devices"; +import CohortMeasurementsApiCard from "@/components/features/cohorts/cohort-measurements-api-card"; +import { usePageTitle } from "@/context/page-title-context"; +import { CohortOrganizationsCard } from "@/components/features/cohorts/cohort-organizations-card"; + +// Loading skeleton for content grid +const ContentGridSkeleton = () => ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+); + +export default function CohortDetailsPage() { + const router = useRouter(); + const params = useParams(); + const cohortId = params?.id as string; + + const { data: cohort, isLoading, error } = useCohortDetails(cohortId); + + usePageTitle({ title: cohort?.name || "Cohort Details", section: "Cohorts" }); + + const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE); + const [cohortDetails, setCohortDetails] = useState<{ + name: string; + id: string; + visibility: boolean; + cohort_tags: string[]; + }>({ + name: "", + id: "", + visibility: true, + cohort_tags: [], + }); + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [showAssignDialog, setShowAssignDialog] = useState(false); + const [showUnassignDialog, setShowUnassignDialog] = useState(false); + + const handleOpenDetails = () => setShowDetailsModal(true); + const handleCloseDetails = () => setShowDetailsModal(false); + + useEffect(() => { + if (cohort) { + setCohortDetails({ + name: cohort.name, + id: cohort._id, + visibility: cohort.visibility, + cohort_tags: cohort.cohort_tags || [], + }); + } + }, [cohort]); + + const devices = useMemo(() => cohort?.devices || [], [cohort]); + + const handleAssignSuccess = () => { + setShowAssignDialog(false); + }; + + const handleUnassignSuccess = () => { + setShowUnassignDialog(false); + }; + + return ( + +
+
+ router.back()} Icon={AqArrowLeft}> + Back + + +
+ + + + {isLoading ? ( + + ) : error ? ( +
+ Unable to load cohort details:{" "} + {String((error as Error)?.message || "Unknown error")} +
+ ) : ( +
+ {/* Cards section */} +
+ + + +
+ + {/* Devices list */} +
+
+

Cohort devices

+
+ setShowAssignDialog(true)} + disabled={!canUpdateDevice} + permission={PERMISSIONS.DEVICE.UPDATE} + Icon={AqPlus} + padding="px-3 py-1.5" + className="text-sm font-medium" + > + Add Devices + + setShowUnassignDialog(true)} + disabled={!canUpdateDevice} + permission={PERMISSIONS.DEVICE.UPDATE} + Icon={AqMinusCircle} + padding="px-3 py-1.5" + className="border-red-700 hover:bg-red-700 text-red-700 text-sm font-medium" + > + Remove Devices + +
+
+ { + router.push(`/admin/cohorts/${cohortId}/devices/${device._id}`); + }} + /> +
+ +
+ )} +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx b/src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx new file mode 100644 index 0000000000..588b38e098 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { CreateCohortDialog } from "@/components/features/cohorts/create-cohort"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import ReusableTable, { TableColumn } from "@/components/shared/table/ReusableTable"; +import { useCohorts, useUserCohorts } from "@/core/hooks/useCohorts"; +import { Cohort } from "@/app/types/cohorts"; +import { useState, useMemo } from "react"; +import { format } from 'date-fns'; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { AqPlus } from "@airqo/icons-react"; +import { CreateCohortFromSelectionDialog } from "@/components/features/cohorts/create-cohort-from-cohorts"; +import { AssignCohortsToGroupDialog } from "@/components/features/cohorts/assign-cohorts-to-group"; +import { useServerSideTableState } from "@/core/hooks/useServerSideTableState"; +import { usePageTitle } from "@/context/page-title-context"; + +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; + +type CohortRow = { + id: string; + name: string; + numberOfDevices: number; + visibility: boolean; + cohort_tags?: string[]; + dateCreated?: string; +} + +export default function CohortsPage() { + usePageTitle({ title: "Cohorts", section: "Administrative Panel" }); + + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + + const { + pagination, setPagination, + searchTerm, setSearchTerm, + sorting, setSorting + } = useServerSideTableState({ initialPageSize: 25 }); + + const [view, setView] = useState<'organization' | 'user'>('organization'); + + // Tag Logic + const defaultTag = DEFAULT_COHORT_TAGS[0]?.value || "All"; + const urlTag = searchParams.get('tags'); + const selectedTag = urlTag || defaultTag; + + const handleTagClick = (tag: string) => { + const params = new URLSearchParams(searchParams); + if (tag === 'All') { + params.delete('tags'); + params.set('tags', 'All'); + } else { + params.set('tags', tag); + } + setPagination(prev => ({ ...prev, pageIndex: 0 })); + router.replace(`${pathname}?${params.toString()}`); + }; + + // Count Queries (Stable, always enabled, minimal payload, no search/sort) + const { meta: orgCountMeta, isFetching: isFetchingOrgCount } = useCohorts({ + page: 1, + limit: 1, + }); + + const { meta: userCountMeta, isFetching: isFetchingUserCount } = useUserCohorts({ + page: 1, + limit: 1, + }); + + // Table Queries (Dynamic, enabled only when active) + const { cohorts: orgCohorts, meta: orgMeta, isFetching: isFetchingOrg, error: orgError } = useCohorts({ + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + search: searchTerm, + sortBy: sorting[0]?.id, + order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined, + tags: selectedTag === "All" ? undefined : selectedTag, + }, { + enabled: view === 'organization' + }); + + const { cohorts: userCohorts, meta: userMeta, isFetching: isFetchingUser, error: userError } = useUserCohorts({ + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + search: searchTerm, + sortBy: sorting[0]?.id, + order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined, + }, { + enabled: view === 'user' + }); + + const cohorts = view === 'organization' ? orgCohorts : userCohorts; + const meta = view === 'organization' ? orgMeta : userMeta; + const isFetching = view === 'organization' ? isFetchingOrg : isFetchingUser; + const error = view === 'organization' ? orgError : userError; + + const pageCount = meta?.totalPages ?? 0; + + const [showCreateCohortModal, setShowCreateCohortModal] = useState(false); + const [showCreateFromCohorts, setShowCreateFromCohorts] = useState(false); + const [showAssignToGroup, setShowAssignToGroup] = useState(false); + const [selectedCohortIds, setSelectedCohortIds] = useState([]); + + const rows: CohortRow[] = useMemo(() => (cohorts || []).map((c: Cohort) => ({ + ...c, + id: c._id, + dateCreated: c.createdAt, + })), [cohorts]); + + const columns: TableColumn[] = [ + { + key: "name", + label: "Cohort Name", + sortable: true, + render: (v) => v ?? "-" + }, + { + key: "numberOfDevices", + label: "Number of devices", + sortable: true, + render: (v) => (v ?? 0) + }, + { + key: "visibility", + label: "Visibility", + sortable: true, + render: (v) => ( + {v ? "Public" : "Private"} + ) + }, + { + key: "cohort_tags", + label: "Tags", + sortable: true, + render: (value) => { + const tags = Array.isArray(value) ? value : []; + if (tags.length === 0) return "-"; + return ( +
+ {tags.map((tag, index) => { + const normalized = String(tag || "").replace(/_/g, " "); + const displayTag = normalized.toLowerCase() === "external device" ? "misc" : normalized; + return ( + + {displayTag} + + ); + })} +
+ ); + } + }, + { + key: "dateCreated", + label: "Date created", + sortable: true, + render: (value) => { + const date = new Date(value as string); + return format(date, "MMM d yyyy, h:mm a"); + } + } + ] + + const tableActions = [ + { + label: "Create cohort from selection", + value: "create-from-cohorts", + handler: (ids: (string | number)[]) => { + setSelectedCohortIds(ids.map(String)); + setShowCreateFromCohorts(true); + }, + }, + { + label: "Assign to Organization", + value: "assign-to-group", + handler: (ids: (string | number)[]) => { + setSelectedCohortIds(ids.map(String)); + setShowAssignToGroup(true); + }, + }, + ]; + + return ( + +
+
+
+

Cohorts

+

+ Manage and organize your device cohorts +

+
+ + +
+ + {view === 'organization' && ( +
+ {[...DEFAULT_COHORT_TAGS.map(t => ({ value: t.value, label: t.label })), { value: 'All', label: 'All Cohorts' }].map(({ value: tag, label }) => ( + + ))} +
+ )} +
+ { + setShowCreateCohortModal(true); + }} + Icon={AqPlus} + > + Create Cohort + +
+ +
+ { + const row = item as CohortRow; + if (row?.id) router.push(`/admin/cohorts/${row.id}`) + }} + emptyState={error ? (error.message || "unable to load cohorts") : "No cohorts available"} + multiSelect + onSelectedIdsChange={(ids: (string | number)[]) => setSelectedCohortIds(ids.map(String))} + actions={tableActions} + serverSidePagination + pageCount={pageCount} + pagination={pagination} + onPaginationChange={setPagination} + onSearchChange={setSearchTerm} + searchTerm={searchTerm} + sorting={sorting} + onSortingChange={setSorting} + searchable + /> +
+ + + + + +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx new file mode 100644 index 0000000000..8feda80022 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useGridDetails } from "@/core/hooks/useGrids"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { AqArrowLeft } from "@airqo/icons-react"; +import GridDetailsCard from "@/components/features/grids/grid-details-card"; +import GridMeasurementsApiCard from "@/components/features/grids/grid-measurements-api-card"; +import EditGridDetailsDialog from "@/components/features/grids/edit-grid-details-dialog"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import ClientPaginatedSitesTable from "@/components/features/sites/client-paginated-sites-table"; +import { usePageTitle } from "@/context/page-title-context"; + +const ContentGridSkeleton = () => ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+); + +export default function GridDetailsPage() { + const router = useRouter(); + const { id } = useParams(); + const gridId = id.toString(); + const { gridDetails, isLoading, error } = useGridDetails(gridId); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + usePageTitle({ title: gridDetails?.name || "Grid Details", section: "Grids" }); + + if (error) { + return ( +
+ + + Error + {error?.message} + +
+ ); + } + + return ( + +
+ {/* Header */} +
+ router.back()} Icon={AqArrowLeft}> + Back + +
+ + {isLoading ? ( + + ) : !gridDetails ? null : + <> +
+
+ setIsEditDialogOpen(true)} loading={isLoading} /> +
+
+ +
+
+ + + setIsEditDialogOpen(false)} grid={gridDetails} /> + + } +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/grids/page.tsx b/src/vertex-template/app/(authenticated)/admin/grids/page.tsx new file mode 100644 index 0000000000..ceda910a0c --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/grids/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { CreateGridForm } from "@/components/features/grids/create-grid"; +import { CreateAdminLevel } from "@/components/features/grids/create-admin-level"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import GridsTable from "@/components/features/grids/grids-list-table"; + +export default function GridsPage() { + return ( + +
+
+
+

Grids

+

+ Manage and organize your monitoring grids +

+
+
+ + +
+
+ + +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/layout.tsx b/src/vertex-template/app/(authenticated)/admin/layout.tsx new file mode 100644 index 0000000000..71c1c29cea --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; + +/** + * Admin Layout + * + * This layout wraps all admin pages with a RouteGuard to ensure + * that only AIRQO ADMIN users in the personal context (with airqo group active) + * can access admin features. + * + * Applies to all pages in the /admin/* routes. + */ +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx new file mode 100644 index 0000000000..8c1cad41b9 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useParams } from "next/navigation"; +import DeviceDetailsLayout from "@/components/features/devices/device-details-layout"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function AdminNetworkDeviceDetailsPage() { + const params = useParams(); + const deviceId = params?.deviceId; + + if (!deviceId || typeof deviceId !== 'string') { + return ( +
+

Invalid device ID

+
+ ); + } + + return ( + + + + ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx new file mode 100644 index 0000000000..c5484e91e1 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { AqArrowLeft } from "@airqo/icons-react"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import NetworkDetailsCard from "@/components/features/networks/network-detail-card"; +import NetworkDevicesTable from "@/components/features/networks/network-device-list-table"; +import { useNetworks } from "@/core/hooks/useNetworks"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { Plus, Upload } from "lucide-react"; +import CreateDeviceModal from "@/components/features/devices/create-device-modal"; +import ImportDeviceModal from "@/components/features/devices/import-device-modal"; +import { useState } from "react"; +import { usePermission } from "@/core/hooks/usePermissions"; +import { NetworkStatsCards } from "@/components/features/networks/NetworkStatsCards"; +import { usePageTitle } from "@/context/page-title-context"; + +// Loading skeleton for content grid +const ContentGridSkeleton = () => ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+); + +export default function NetworkDetailsPage() { + const router = useRouter(); + const params = useParams(); + const networkId = params?.id as string; + + const { networks, isLoading, error } = useNetworks(); + const [isCreateDeviceOpen, setCreateDeviceOpen] = useState(false); + const [isImportDeviceOpen, setImportDeviceOpen] = useState(false); + const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE); + + const network = useMemo(() => { + return networks.find((n) => n._id === networkId); + }, [networks, networkId]); + usePageTitle({ + title: network?.net_name || "Sensor Manufacturer Details", + section: "Sensor Manufacturers", + }); + + const isAirQoNetwork = network?.net_name?.toLowerCase() === "airqo"; + + return ( + +
+
+ router.push("/admin/networks")} Icon={AqArrowLeft}> + Back + +
+ {isAirQoNetwork ? ( + setCreateDeviceOpen(true)} + Icon={Plus} + permission={PERMISSIONS.DEVICE.UPDATE} + > + Add AirQo Device + + ) : ( + setImportDeviceOpen(true)} + Icon={Upload} + permission={PERMISSIONS.DEVICE.UPDATE} + > + Import External Device + + )} +
+
+ + + + + {isLoading ? ( + + ) : error ? ( +
+ Unable to load Sensor Manufacturer details:{" "} + {String((error as Error)?.message || "Unknown error")} +
+ ) : !network ? ( +
+ Sensor Manufacturer not found +
+ ) : ( +
+ {/* Network basic info card */} +
+ +
+ + {/* Network Stats Cards */} +
+ +
+ + {/* Devices list */} +
+ +
+
+ )} +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/networks/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/page.tsx new file mode 100644 index 0000000000..e3f26ffb44 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import ClientPaginatedNetworksTable from "@/components/features/networks/client-paginated-networks-table"; +import { CreateNetworkForm } from "@/components/features/networks/create-network-form"; +import { useNetworks } from "@/core/hooks/useNetworks"; + +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function NetworksPage() { + const { networks, isFetching, error } = useNetworks(); + + return ( + +
+
+
+

Sensor Manufacturers

+ +
+ +
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx b/src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx new file mode 100644 index 0000000000..044cb0cc47 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useMemo } from "react"; +import axios from "axios"; +import { useRouter } from "next/navigation"; +import NetworkRequestTable from "@/components/features/networks/request-table"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { NetworkCreationRequest } from "@/core/apis/networks"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useBanner } from "@/context/banner-context"; + +type TabStatus = "all" | "pending" | "under_review" | "approved" | "denied"; + +interface NetworkRequestsClientProps { + initialRequests: NetworkCreationRequest[]; +} + +export default function NetworkRequestsClient({ initialRequests }: NetworkRequestsClientProps) { + const router = useRouter(); + const [activeTab, setActiveTab] = useState("pending"); + const [isUpdating, setIsUpdating] = useState(false); + const { showBanner } = useBanner(); + // Action management + const [selectedRequest, setSelectedRequest] = useState(null); + const [actionType, setActionType] = useState<'approve' | 'deny' | 'review' | null>(null); + const [notes, setNotes] = useState(""); + + const handleRefresh = () => { + router.refresh(); + }; + + const counts = useMemo(() => ({ + all: initialRequests.length, + pending: initialRequests.filter(r => r.status === "pending").length, + under_review: initialRequests.filter(r => r.status === "under_review").length, + approved: initialRequests.filter(r => r.status === "approved").length, + denied: initialRequests.filter(r => r.status === "denied").length, + }), [initialRequests]); + + const filteredRequests = useMemo(() => { + let filtered = initialRequests; + + if (activeTab !== "all") { + filtered = filtered.filter(r => r.status === activeTab); + } + + return filtered; + }, [initialRequests, activeTab]); + + const handleActionRequest = (request: NetworkCreationRequest, type: 'approve' | 'deny' | 'review') => { + setSelectedRequest(request); + setActionType(type); + setNotes(""); + }; + + const confirmAction = async () => { + if (!selectedRequest || !actionType) return; + + setIsUpdating(true); + try { + const response = await axios.put(`/api/devices/network-creation-requests/${selectedRequest._id}/${actionType}`, { + reviewer_notes: notes + }); + + showBanner({ + message: response.data.message || 'Status updated successfully', + severity: 'success', + scoped: false + }); + + setSelectedRequest(null); + setActionType(null); + router.refresh(); // Invalidate server data + } catch (error: unknown) { + showBanner({ + message: `Action failed: ${getApiErrorMessage(error)}`, + severity: 'error', + scoped: false + }); + } finally { + setIsUpdating(false); + } + }; + + return ( + +
+ {/* Page Header */} +
+
+

Sensor Manufacturer Requests

+

+ Manage and review new sensor requests across the platform. +

+
+
+ + {/* Filters & Tabs */} +
+ setActiveTab(v as TabStatus)} className="w-fit"> + + + Pending ({counts.pending}) + + + In Review ({counts.under_review}) + + + Approved ({counts.approved}) + + + Denied ({counts.denied}) + + + All ({counts.all}) + + + + + + Refresh + +
+ + {/* Table Content */} +
+ +
+ + {/* Status Action Dialog */} + setSelectedRequest(null)} + title={`${actionType?.charAt(0).toUpperCase()}${actionType?.slice(1)} Request`} + size="md" + primaryAction={{ + label: isUpdating ? "Processing..." : "Confirm", + onClick: confirmAction, + disabled: isUpdating + }} + secondaryAction={{ + label: "Cancel", + onClick: () => setSelectedRequest(null), + variant: "outline" + }} + > +
+

+ You are about to {actionType} the request for {selectedRequest?.net_name}. +

+ setNotes(e.target.value)} + rows={4} + /> + {actionType === 'deny' && ( +

+ Note: Denial notes will be included in the email notification to the requester. +

+ )} +
+
+
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx new file mode 100644 index 0000000000..1b9d5eb98a --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx @@ -0,0 +1,39 @@ +import { getServerSession } from "next-auth/next"; +import { options } from "@/app/api/auth/[...nextauth]/options"; +import NetworkRequestsClient from "./NetworkRequestsClient"; +import { NetworkCreationRequest } from "@/core/apis/networks"; +import logger from "@/lib/logger"; + +import { notFound } from "next/navigation"; +import { networkService } from "@/core/services/network-service"; + +async function getNetworkRequests(): Promise { + try { + const session = await getServerSession(options); + const token = (session as { user?: { accessToken?: string } })?.user?.accessToken; + + if (!token) { + return []; + } + + const adminSecret = process.env.ADMIN_SECRET; + if (!adminSecret) { + logger.error("ADMIN_SECRET is not defined in environment variables"); + return []; + } + + return await networkService.getNetworkCreationRequests(token, adminSecret); + } catch (error) { + if (error instanceof Error && error.message === "NOT_FOUND") { + notFound(); + } + logger.error(`Error fetching network requests on server: ${error}`); + return []; + } +} + +export default async function NetworkRequestsPage() { + const requests = await getNetworkRequests(); + + return ; +} diff --git a/src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx new file mode 100644 index 0000000000..f63017e579 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import React from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useShippingBatchDetails, useGenerateShippingLabels } from '@/core/hooks/useDevices'; +import { format } from 'date-fns'; +import { ArrowLeft } from 'lucide-react'; +import ReusableTable, { TableColumn } from '@/components/shared/table/ReusableTable'; +import { ShippingStatusDevice } from '@/app/types/devices'; +import { Skeleton } from "@/components/ui/skeleton"; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import { AqArrowLeft } from '@airqo/icons-react'; +import { useBanner } from '@/context/banner-context'; +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; +import ShippingLabelPrintModal from '@/components/features/shipping/ShippingLabelPrintModal'; +import { useQueryClient } from '@tanstack/react-query'; +import { useState, useCallback } from 'react'; +import { usePageTitle } from '@/context/page-title-context'; + +type BatchDevice = ShippingStatusDevice & { + id: string | number; + createdAt?: string; + [key: string]: unknown; +}; + +const BatchDetailsPage = () => { + const params = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const batchId = params.batchId as string; + const { data, isLoading, error } = useShippingBatchDetails(batchId); + const { showBanner } = useBanner(); + const { mutate: generateLabels, isPending: isGenerating, data: labelsData } = useGenerateShippingLabels({ + onSuccess: (data) => { + if (data.success) { + showBanner({ severity: 'success', message: `Successfully generated ${data.shipping_labels.labels.length} shipping label(s)`, scoped: false }); + setShowLabelModal(true); + } + }, + onError: (error) => { + showBanner({ severity: 'error', message: `Label Generation Failed: ${getApiErrorMessage(error)}`, scoped: false }); + }, + }); + const [showLabelModal, setShowLabelModal] = useState(false); + usePageTitle({ + title: data?.batch?.batch_name || "Shipping Batch", + section: "Shipping", + }); + + const handleGenerateLabels = useCallback((ids: (string | number)[]) => { + if (!ids || ids.length === 0) { + showBanner({ severity: 'error', message: 'Please select at least one device', scoped: false }); + return; + } + + const selectedDeviceNames = (data?.batch?.devices || []) + .filter(device => ids.includes(device._id || device.name)) + .map(device => device.name) + .filter(name => name && name.trim().length > 0); + + if (selectedDeviceNames.length === 0) { + showBanner({ severity: 'error', message: 'Selected devices have no valid names', scoped: false }); + return; + } + + generateLabels(selectedDeviceNames); + }, [generateLabels, data?.batch?.devices, showBanner]); + + const handleGenerateAllLabels = useCallback(() => { + const allDeviceNames = (data?.batch?.devices || []) + .filter(device => device.claim_status !== 'claimed') + .map(device => device.name) + .filter(name => name && name.trim().length > 0); + + if (allDeviceNames.length === 0) { + showBanner({ severity: 'error', message: 'No unclaimed devices found with valid names in this batch', scoped: false }); + return; + } + + generateLabels(allDeviceNames); + }, [generateLabels, data?.batch?.devices, showBanner]); + + const handleCloseModal = useCallback(() => { + setShowLabelModal(false); + queryClient.invalidateQueries({ queryKey: ['shippingBatchDetails', batchId] }); + }, [queryClient, batchId]); + + const actions = [ + { + label: isGenerating ? 'Generating...' : 'Generate Labels', + value: 'generate_labels', + handler: handleGenerateLabels + } + ]; + + const columns: TableColumn[] = [ + { + key: 'name', + label: 'Device Name', + render: (value) => {value as string} + }, + { + key: 'claim_token', + label: 'Claim Token', + render: (value) => {(value as string | null) || '-'} + }, + { + key: 'claim_status', + label: 'Status', + render: (value) => ( + + {value ? (value as string).charAt(0).toUpperCase() + (value as string).slice(1) : 'Unknown'} + + ) + }, + { + key: 'createdAt', + label: 'Created At', + render: (value) => { + const date = value ? new Date(value as string) : null; + return + {date && !isNaN(date.getTime()) ? format(date, 'MMM dd, yyyy HH:mm') : '-'} + ; + } + }, + ]; + + if (!isLoading && (error || !data?.batch)) { + return ( +
+
+ Error loading batch details: {error?.message || 'Batch not found'} +
+ +
+ ); + } + + const batch = data?.batch; + + const tableData: BatchDevice[] = (batch?.devices || []).map(device => ({ + ...device, + id: device._id || device.name + })); + + return ( +
+ {/* Header */} + router.back()} Icon={AqArrowLeft}> + Back + +
+
+ {isLoading ? ( +
+ + +
+ ) : ( + <> +

+ {batch?.batch_name || 'Unnamed Batch'} +

+

+ Batch ID: {batch?._id} +

+ + )} +
+ {!isLoading && ( +
+ + {isGenerating ? 'Generating...' : 'Generate Labels'} + +
+ )} +
+ + {/* Devices Table */} +
+ device.claim_status !== 'claimed'} + /> +
+ + {/* Label Print Modal */} + {labelsData?.success && ( + + )} +
+ ); +}; + +export default BatchDetailsPage; diff --git a/src/vertex-template/app/(authenticated)/admin/shipping/page.tsx b/src/vertex-template/app/(authenticated)/admin/shipping/page.tsx new file mode 100644 index 0000000000..cdfe3fcf5e --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/shipping/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import React, { useState } from 'react'; +import { useShippingStatus } from '@/core/hooks/useDevices'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import { AqPlus } from '@airqo/icons-react'; +import { PrepareShippingModal } from '@/components/features/shipping/PrepareShippingModal'; +import ShippingBatchesTable from '@/components/features/shipping/ShippingBatchesTable'; +import { Skeleton } from "@/components/ui/skeleton"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +const ShippingPage = () => { + const [showPrepareModal, setShowPrepareModal] = useState(false); + + return ( + +
+
+
+

Device Shipping Management

+

Manage device shipping status and labels

+
+ setShowPrepareModal(true)} + Icon={AqPlus} + > + Prepare New Batch + +
+ +
+ +
+ +
+ +
+ + setShowPrepareModal(false)} + /> +
+
+ ); +}; + +const ShippingStatus = () => { + const { data: statusData, isLoading } = useShippingStatus(); + + return ( +
+ {isLoading ? ( +
+ {[...Array(4)].map((_, i) => ( +
+ + +
+ ))} +
+ ) : ( + statusData?.shipping_status?.summary && ( +
+
+

{statusData.shipping_status.summary.total_devices}

+

Total Devices

+
+
+

{statusData.shipping_status.summary.prepared_for_shipping}

+

Prepared

+
+
+

{statusData.shipping_status.summary.claimed_devices}

+

Claimed

+
+
+

{statusData.shipping_status.summary.deployed_devices}

+

Deployed

+
+
+ ) + )} +
+ ); +}; + +export default ShippingPage; diff --git a/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx new file mode 100644 index 0000000000..d3f18bd909 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { SiteDevice } from "@/app/types/sites"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { CircuitBoard } from "lucide-react"; + +interface SiteDevicesProps { + devices: SiteDevice[]; +} + +export function SiteDevices({ devices }: SiteDevicesProps) { + if (!devices.length) { + return ( +
+
+ +

No devices found

+

+ There are no devices deployed at this site yet. +

+
+
+ ); + } + + return ( +
+ + + + Name + Description + Site + Is Primary + Is Co-located + Added On + Deployment status + + + + {devices.map((device) => ( + + {device.name} + {device.description || "N/A"} + {device.site || "N/A"} + + + {device.isPrimary ? "Yes" : "No"} + + + + + {device.isCoLocated ? "Yes" : "No"} + + + {device.registrationDate} + + + {device.deploymentStatus} + + + + ))} + +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx new file mode 100644 index 0000000000..3f9dc28cbb --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useParams } from "next/navigation"; +import DeviceDetailsLayout from "@/components/features/devices/device-details-layout"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function AdminSiteDeviceDetailsPage() { + const params = useParams(); + const deviceId = params?.deviceId; + + if (!deviceId || typeof deviceId !== 'string') { + return ( +
+

Invalid device ID

+
+ ); + } + + return ( + + + + ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx new file mode 100644 index 0000000000..a2393823d3 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AqArrowLeft } from "@airqo/icons-react"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { useSiteDetails, useRefreshSiteMetadata } from "@/core/hooks/useSites"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { useParams } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { SiteInformationCard } from "@/components/features/sites/site-information-card"; +import { SiteMobileAppCard } from "@/components/features/sites/site-mobile-app-card"; +import { EditSiteDetailsDialog } from "@/components/features/sites/edit-site-details-dialog"; +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; +import SiteMeasurementsApiCard from "@/components/features/sites/site-measurements-api-card"; +import SiteActivityCard from "@/components/features/sites/site-activity-card"; +import { usePageTitle } from "@/context/page-title-context"; + +const ContentGridSkeleton = () => ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+); + +export default function SiteDetailsPage() { + const params = useParams(); + const siteId = params.id as string; + const { data: site, isLoading, error } = useSiteDetails(siteId); + const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata(); + const router = useRouter(); + const [editSection, setEditSection] = useState<"general" | "mobile" | null>( + null + ); + usePageTitle({ title: site?.name || "Site Details", section: "Sites" }); + + if (error) { + return ( +
+ + + Error + {error.message} + +
+ ); + } + + return ( +
+
+ router.back()} + Icon={AqArrowLeft} + > + Back + + + refreshMetadata(siteId)} + disabled={isRefreshing || isLoading || !site} + loading={isRefreshing} + Icon={RefreshCw} + className="text-xs font-medium" + > + Refresh Metadata + +
+ + {isLoading ? ( + + ) : !site ? ( +
+ Site not found. +
+ ) : ( + <> +
+
+ setEditSection("general")} + /> +
+ +
+ setEditSection("mobile")} + /> + +
+ +
+ +
+
+
+ { + router.push(`/admin/sites/${siteId}/devices/${device._id}`); + }} + /> +
+ !open && setEditSection(null)} + site={site} + section={editSection || "general"} + /> + + )} +
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/sites/page.tsx b/src/vertex-template/app/(authenticated)/admin/sites/page.tsx new file mode 100644 index 0000000000..aa1df046ef --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/sites/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import SitesTable from "@/components/features/sites/sites-list-table"; +import { SiteStatsCards } from "@/components/features/sites/site-stats-cards"; +import { usePageTitle } from "@/context/page-title-context"; +import dynamic from "next/dynamic"; + +const CreateSiteForm = dynamic(() => + import('@/components/features/sites/create-site-form').then(mod => mod.CreateSiteForm), + { + ssr: false, + loading: () =>
+ } +); + +export default function SitesPage() { + usePageTitle({ title: "Sites", section: "Administrative Panel" }); + + return ( + +
+
+
+

Sites

+

+ Manage and organize your monitoring sites +

+
+ +
+ +
+
+ +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx b/src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx new file mode 100644 index 0000000000..f34c2d053b --- /dev/null +++ b/src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { AqArrowLeft } from "@airqo/icons-react"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { useCohortDetails } from "@/core/hooks/useCohorts"; +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; +import CohortDetailsCard from "@/components/features/cohorts/cohort-detail-card"; +import CohortDetailsModal from "@/components/features/cohorts/edit-cohort-details-modal"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import CohortMeasurementsApiCard from "@/components/features/cohorts/cohort-measurements-api-card"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { usePageTitle } from "@/context/page-title-context"; + +const ContentGridSkeleton = () => ( +
+ {[...Array(2)].map((_, i) => ( +
+ ))} +
+); + +export default function CohortDetailsPage() { + const router = useRouter(); + const params = useParams(); + const cohortId = typeof params?.id === "string" ? params.id : ""; + + const { data: cohort, isLoading, error } = useCohortDetails(cohortId, { enabled: !!cohortId }); + usePageTitle({ title: cohort?.name || "Cohort Details", section: "Cohorts" }); + + const [cohortDetails, setCohortDetails] = useState<{ + name: string; + id: string; + visibility: boolean; + cohort_tags: string[]; + }>({ + name: "", + id: "", + visibility: true, + cohort_tags: [], + }); + + const [showDetailsModal, setShowDetailsModal] = useState(false); + + useEffect(() => { + if (cohort) { + setCohortDetails({ + name: cohort.name, + id: cohort._id, + visibility: cohort.visibility, + cohort_tags: cohort.cohort_tags || [], + }); + } + }, [cohort]); + + const devices = useMemo(() => cohort?.devices || [], [cohort]); + + if (!cohortId) { + return ( +
+

Invalid cohort ID

+
+ ); + } + + return ( + +
+
+ router.back()} Icon={AqArrowLeft}> + Back + +
+ + {isLoading ? ( + + ) : error ? ( + + + Error + + {String((error as Error)?.message || "Unable to load cohort details")} + + + ) : ( +
+ {/* Top Cards: Details & API */} +
+ setShowDetailsModal(true)} + loading={isLoading} + /> + +
+ + {/* Devices List */} +
+

Cohort Devices

+ +
+ + setShowDetailsModal(false)} + /> +
+ )} +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/cohorts/page.tsx b/src/vertex-template/app/(authenticated)/cohorts/page.tsx new file mode 100644 index 0000000000..e5ba98aa4a --- /dev/null +++ b/src/vertex-template/app/(authenticated)/cohorts/page.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { format } from 'date-fns'; +import { Badge } from "@/components/ui/badge"; +import ReusableTable, { TableColumn } from "@/components/shared/table/ReusableTable"; +import { useCohorts, useGroupCohorts, usePersonalUserCohorts } from "@/core/hooks/useCohorts"; +import { Cohort } from "@/app/types/cohorts"; +import { useServerSideTableState } from "@/core/hooks/useServerSideTableState"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { useAppSelector } from "@/core/redux/hooks"; +import CohortsEmptyState from "@/components/features/cohorts/cohorts-empty-state"; + +type CohortRow = { + id: string; + name: string; + numberOfDevices: number; + visibility: boolean; + dateCreated?: string; +} + +export default function CohortsPage() { + const router = useRouter(); + const { data: session } = useSession(); + const user = useAppSelector((state) => state.user.userDetails); + + const { + pagination, setPagination, + searchTerm, setSearchTerm, + sorting, setSorting + } = useServerSideTableState({ initialPageSize: 25 }); + + const { isExternalOrg, activeGroup, userScope } = useUserContext(); + + const isPersonalScope = userScope === 'personal'; + const userId = (session?.user as { id?: string })?.id || user?._id; + + // --- Personal scope: use personal user cohorts API --- + const { + data: personalCohortIds = [], + isLoading: isLoadingPersonalCohorts, + } = usePersonalUserCohorts(userId, { + enabled: isPersonalScope && !!userId, + }); + + // --- Org scope: use group cohorts --- + const { + data: groupCohortIds, + isLoading: isLoadingGroupCohorts, + } = useGroupCohorts(activeGroup?._id, { + enabled: isExternalOrg && !!activeGroup?._id && !isPersonalScope, + }); + + // Resolve effective cohort IDs based on scope + const targetCohortIds = useMemo(() => { + if (isPersonalScope) return personalCohortIds; + if (isExternalOrg) return groupCohortIds || []; + return []; + }, [isPersonalScope, personalCohortIds, isExternalOrg, groupCohortIds]); + + const isResolvingIds = + (isPersonalScope && isLoadingPersonalCohorts) || + (isExternalOrg && !isPersonalScope && isLoadingGroupCohorts); + + const hasIdsToFetch = targetCohortIds.length > 0; + const shouldFetchCohorts = hasIdsToFetch && !isResolvingIds; + + const { cohorts, meta, isFetching: isFetchingCohorts, error } = useCohorts( + { + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + search: searchTerm, + sortBy: sorting[0]?.id, + order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined, + cohort_id: hasIdsToFetch ? targetCohortIds : undefined, + }, + { enabled: shouldFetchCohorts } + ); + + const pageCount = meta?.totalPages ?? 0; + + const tableLoading = isResolvingIds || (hasIdsToFetch && isFetchingCohorts); + const displayError = (!hasIdsToFetch && !tableLoading) ? null : error; + const showEmptyState = + !tableLoading && + !displayError && + (!hasIdsToFetch || (cohorts && cohorts.length === 0)); + + const rows: CohortRow[] = useMemo(() => (cohorts || []).map((c: Cohort) => ({ + ...c, + id: c._id, + dateCreated: c.createdAt, + })), [cohorts]); + + const columns: TableColumn[] = [ + { + key: "name", + label: "Cohort Name", + sortable: true, + render: (v) => v ?? "-" + }, + { + key: "numberOfDevices", + label: "Number of devices", + sortable: true, + render: (v) => (v ?? 0) + }, + { + key: "visibility", + label: "Visibility", + sortable: true, + render: (v) => ( + {v ? "Public" : "Private"} + ) + }, + { + key: "dateCreated", + label: "Date created", + sortable: true, + render: (value) => { + if (!value) return "-"; + const date = new Date(value as string); + if (isNaN(date.getTime())) return "-"; + return format(date, "MMM d yyyy, h:mm a"); + } + } + ]; + + if (showEmptyState) { + return ( + + + + ); + } + + return ( + +
+
+

Cohorts

+

+ Cohorts are groups of devices claimed by you or your organization. Use them to control data privacy settings and determine whether your device data is public or private. +

+
+ +
+ { + const row = item as CohortRow; + if (row?.id) router.push(`/cohorts/${row.id}`); + }} + emptyState={displayError ? (displayError.message || "Unable to load cohorts") : "No cohorts available"} + serverSidePagination + pageCount={pageCount} + pagination={pagination} + onPaginationChange={setPagination} + onSearchChange={setSearchTerm} + searchTerm={searchTerm} + sorting={sorting} + onSortingChange={setSorting} + searchable + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx b/src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx new file mode 100644 index 0000000000..d8688e08ab --- /dev/null +++ b/src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import React, { useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { AqCollocation, AqPlus } from "@airqo/icons-react"; +import { Upload } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { useMyDevices, useDevices } from "@/core/hooks/useDevices"; +import { useAppSelector } from "@/core/redux/hooks"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { DeviceAssignmentModal } from "@/components/features/devices/device-assignment-modal"; +import ImportDeviceModal from "@/components/features/devices/import-device-modal"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import dynamic from "next/dynamic"; + +const ClaimDeviceModal = dynamic( + () => import("@/components/features/claim/claim-device-modal"), + { ssr: false } +); +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; + +import { OrphanedDevicesAlert } from "@/components/features/devices/orphaned-devices-alert"; +import ReusableButton from "@/components/shared/button/ReusableButton"; + +const MyDevicesPage = () => { + const { userDetails, activeGroup } = useAppSelector((state) => state.user); + const [showAssignmentModal, setShowAssignmentModal] = useState(false); + const [isImportDeviceOpen, setImportDeviceOpen] = useState(false); + const [isClaimModalOpen, setIsClaimModalOpen] = useState(false); + + const { userScope } = useUserContext(); + + const { + data: myDevicesData, + isLoading: isLoadingMyDevices, + error: myDevicesError, + } = useMyDevices(userDetails?._id || "", activeGroup?._id, { + enabled: userScope === 'personal', + }); + + const { + devices: orgDevices, + isLoading: isLoadingOrgDevices, + error: orgDevicesError, + } = useDevices({ + enabled: userScope === 'organisation', + }); + + const devices = React.useMemo(() => { + return userScope === 'personal' + ? myDevicesData?.devices || [] + : orgDevices; + }, [userScope, myDevicesData?.devices, orgDevices]); + const isLoading = userScope === 'personal' ? isLoadingMyDevices : isLoadingOrgDevices; + const error = userScope === 'personal' ? myDevicesError : orgDevicesError; + const searchParams = useSearchParams(); + const rawStatus = searchParams.get("status"); + const statusFilter = ["operational", "transmitting", "not_transmitting", "data_available"].includes(rawStatus || "") + ? rawStatus + : null; + + const filteredDevices = React.useMemo(() => { + if (!devices) return []; + if (!statusFilter) return devices; + + return devices.filter((device) => { + if (statusFilter === "operational") { + return device.rawOnlineStatus === true && device.isOnline === true; + } + + if (statusFilter === "transmitting") { + return device.rawOnlineStatus === true && device.isOnline === false; + } + + if (statusFilter === "not_transmitting") { + return device.rawOnlineStatus === false && device.isOnline === false; + } + + if (statusFilter === "data_available") { + return device.rawOnlineStatus === false && device.isOnline === true; + } + + return true; + }); + }, [devices, statusFilter]); + + if (error) { + return ( + +
+ {/* Header */} +
+
+

My Devices

+

+ Manage your personal and shared devices + {activeGroup && ( + + • Viewing in {activeGroup.grp_title} + + )} +

+
+
+ setIsClaimModalOpen(true)} + Icon={AqPlus} + permission={PERMISSIONS.DEVICE.CLAIM} + > + Add AirQo Device + + setImportDeviceOpen(true)} + Icon={Upload} + > + Import External Device + +
+
+ + {/* Empty State */} + + +
+ +

+ Unable to load devices +

+

+ There was an error loading your devices. Please try again or + contact support if the problem persists. +

+
+ window.location.reload()}> + Retry + +
+
+
+
+
+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+
+

My Devices

+ {statusFilter && ( + + Filtered: {statusFilter.replace("_", " ")} + + )} +
+

+ Manage your personal and shared devices +

+
+
+ setIsClaimModalOpen(true)} + disabled={isLoading} + Icon={AqPlus} + permission={PERMISSIONS.DEVICE.CLAIM} + > + Add AirQo Device + + setImportDeviceOpen(true)} + disabled={isLoading} + Icon={Upload} + > + Import External Device + + {/* + + + + + setShowAssignmentModal(true)}> + Share Device + + + */} +
+
+ + {userDetails?._id && } + + + + {/* Modals */} + + setIsClaimModalOpen(false)} + /> + { + setShowAssignmentModal(false); + }} + onSuccess={() => { + setShowAssignmentModal(false); + }} + /> +
+
+ ); +}; + +export default MyDevicesPage; diff --git a/src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx b/src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx new file mode 100644 index 0000000000..53c78217ba --- /dev/null +++ b/src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useParams } from "next/navigation"; +import DeviceDetailsLayout from "@/components/features/devices/device-details-layout"; + +export default function DeviceDetailsPage() { + const params = useParams(); + const deviceId = params?.id as string; + + return ; +} diff --git a/src/vertex-template/app/(authenticated)/devices/overview/page.tsx b/src/vertex-template/app/(authenticated)/devices/overview/page.tsx new file mode 100644 index 0000000000..182dcc69c2 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/devices/overview/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Upload } from "lucide-react"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { usePermission } from "@/core/hooks/usePermissions"; +import ImportDeviceModal from "@/components/features/devices/import-device-modal"; +import DevicesTable from "@/components/features/devices/device-list-table"; +import dynamic from "next/dynamic"; + +const ClaimDeviceModal = dynamic( + () => import("@/components/features/claim/claim-device-modal"), + { ssr: false } +); +import ReusableButton from "@/components/shared/button/ReusableButton"; + +export default function DevicesPage() { + const [isImportDeviceOpen, setImportDeviceOpen] = useState(false); + const [isClaimModalOpen, setIsClaimModalOpen] = useState(false); + + // Permission checks + const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE); + + return ( + +
+
+
+

Devices

+

+ Manage and organize your devices. +

+
+
+
+ setIsClaimModalOpen(true)} + Icon={Plus} + permission={PERMISSIONS.DEVICE.CLAIM} + > + Add AirQo Device + + + setImportDeviceOpen(true)} + Icon={Upload} + permission={PERMISSIONS.DEVICE.CLAIM} + > + Import External Device + +
+
+ + {/* Modal Components */} + + setIsClaimModalOpen(false)} + /> + + +
+ + ); +} diff --git a/src/vertex-template/app/(authenticated)/home/page.tsx b/src/vertex-template/app/(authenticated)/home/page.tsx new file mode 100644 index 0000000000..fa0bf88607 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/home/page.tsx @@ -0,0 +1,694 @@ +"use client"; + +import React from "react"; +import dynamic from "next/dynamic"; +import { useSession } from "next-auth/react"; +import { Plus, Upload, AlertTriangle } from "lucide-react"; +import { useAppSelector, useAppDispatch } from "@/core/redux/hooks"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { usePermissions } from "@/core/hooks/usePermissions"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useDevices, useMyDevices } from "@/core/hooks/useDevices"; +import { useGroupCohorts, usePersonalUserCohorts } from "@/core/hooks/useCohorts"; +import { useQueryClient } from "@tanstack/react-query"; +import ContextHeader from "@/components/features/home/context-header"; +import NetworkVisibilityCard from "@/components/features/home/network-visibility-card"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import OnboardingChecklist from "@/components/features/home/onboarding-checklist"; +import { cn } from "@/lib/utils"; +import { Device } from "@/app/types/devices"; +import { Group } from "@/app/types/groups"; +import { useGroupDetails, useUpdateGroupOnboarding } from "@/core/hooks/useGroups"; +import { updateActiveGroupOnboarding } from "@/core/redux/slices/userSlice"; +import { formatTitle } from "@/components/features/org-picker/organization-picker"; +import ReusableToast from "@/components/shared/toast/ReusableToast"; +import logger from "@/lib/logger"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; + +// ─── Checklist localStorage helpers ────────────────────────────────────────── +// Keyed per org/user so state is independent across workspace switches. + +function getChecklistKey(orgId: string) { + return `vertex_onboarding_${orgId}`; +} + +function getChecklistState(orgId: string): { + completedSteps: string[]; + dismissed: boolean; +} { + if (typeof window === "undefined") return { completedSteps: [], dismissed: false }; + try { + const raw = window.localStorage.getItem(getChecklistKey(orgId)); + return raw ? JSON.parse(raw) : { completedSteps: [], dismissed: false }; + } catch { + return { completedSteps: [], dismissed: false }; + } +} + +function saveChecklistState( + orgId: string, + state: { completedSteps: string[]; dismissed: boolean } +) { + try { + window.localStorage.setItem(getChecklistKey(orgId), JSON.stringify(state)); + } catch { + // localStorage unavailable — fail silently + } +} + +// ─── Skeletons ──────────────────────────────────────────────────────────────── + +const StatsSkeleton = () => ( +
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ +
+ + +
+
+ +
+
+
+ ))} +
+
+); + +// ─── Lazy-loaded heavy components ───────────────────────────────────────────── + +const DashboardStatsCards = dynamic( + () => import("@/components/features/dashboard/stats-cards").then((mod) => ({ + default: mod.DashboardStatsCards, + })), + { ssr: false, loading: () => } +); + +const ClaimDeviceModal = dynamic( + () => import("@/components/features/claim/claim-device-modal"), + { ssr: false } +); + +const ImportDeviceModal = dynamic( + () => import("@/components/features/devices/import-device-modal"), + { ssr: false } +); + +const AssignCohortDevicesDialog = dynamic( + () => import("@/components/features/cohorts/assign-cohort-devices").then(mod => ({ default: mod.AssignCohortDevicesDialog })), + { ssr: false } +); + +const LoginFeedbackToast = dynamic( + () => import("@/components/features/feedback/login-feedback-toast"), + { ssr: false } +); + +// ─── Page component ─────────────────────────────────────────────────────────── + +const WelcomePage = () => { + const { data: session } = useSession(); + const { + userContext, + userScope, + hasError, + error, + isLoading: isLoadingUserContext, + } = useUserContext(); + + const [isClaimModalOpen, setIsClaimModalOpen] = React.useState(false); + const [isImportModalOpen, setIsImportModalOpen] = React.useState(false); + const [justCompletedClaim, setJustCompletedClaim] = React.useState(false); + const [isAddDeviceChoiceOpen, setIsAddDeviceChoiceOpen] = React.useState(false); + const [isAssignCohortModalOpen, setIsAssignCohortModalOpen] = React.useState(false); + const [newlyClaimedDevice, setNewlyClaimedDevice] = React.useState[] | undefined + >(); + const [accordionItems, setAccordionItems] = React.useState(["stats", "visibility"]); + const [highlightVisibility, setHighlightVisibility] = React.useState(false); + const visibilityRef = React.useRef(null); + const queryClient = useQueryClient(); + const dispatch = useAppDispatch(); + const isMounted = React.useRef(true); + + React.useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const user = useAppSelector((state) => state.user.userDetails); + const userId = (session?.user as { id?: string })?.id || user?._id; + + // Stable key per workspace — personal users get their own key, org users get their + // actual group ID so switching Kampala ↔ Wakiso gives independent checklist state. + const activeGroup = useAppSelector((state) => state.user.activeGroup); + const orgId = userContext === "personal" + ? `personal_${userId}` + : activeGroup?._id + ? `org_${activeGroup._id}` + : null; + + const { data: groupDetailsData, isLoading: isLoadingGroupDetails } = useGroupDetails(activeGroup?._id as string, { + enabled: userScope === "organisation" && !!activeGroup?._id, + staleTime: 5 * 60 * 1000, + }); + + const groupDetails = groupDetailsData?.group; + + const [localChecklistState, setLocalChecklistState] = React.useState(() => + getChecklistState(orgId ?? "") + ); + + React.useEffect(() => { + if (orgId && userScope === "personal") { + setLocalChecklistState(getChecklistState(orgId)); + } + }, [orgId, userScope]); + + const activeChecklistState = React.useMemo(() => { + if (userScope === "organisation") { + const checklistSrc = groupDetails?.onboarding_checklist || activeGroup?.onboarding_checklist; + return { + completedSteps: checklistSrc?.completed_steps || [], + dismissed: checklistSrc?.is_dismissed || false, + }; + } + return localChecklistState; + }, [userScope, activeGroup?.onboarding_checklist, groupDetails?.onboarding_checklist, localChecklistState]); + + // ── Permissions ──────────────────────────────────────────────────────────── + const permissionsToCheck = [PERMISSIONS.DEVICE.UPDATE]; + const permissionsMap = usePermissions(permissionsToCheck); + const canClaimDevice = permissionsMap[PERMISSIONS.DEVICE.UPDATE]; + + // ── Device data ──────────────────────────────────────────────────────────── + const { devices: groupDevices, isLoading: isLoadingGroupDevices } = useDevices({ + limit: 1, + enabled: userScope === "organisation", + }); + + const { data: myDevicesData, isLoading: isLoadingMyDevices } = useMyDevices( + userId || "", + undefined, + { enabled: !!userId && userScope === "personal" } + ); + + // Cohort data — used to auto-detect step 2 completion + const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, { + enabled: userScope === "organisation" && !!activeGroup?._id, + }); + const { data: personalCohortIds } = usePersonalUserCohorts(userId, { + enabled: !!userId && userScope === "personal", + }); + + // ── Auto-sync step completion from real data ───────────────────────────── + // This ensures that if an org already has devices/cohorts when a user first + // logs in, steps 1 and 2 are immediately shown as complete. + const autoSteps = React.useMemo(() => { + if (!orgId) return []; + + const hasDevices = + userScope === "personal" + ? (myDevicesData?.devices ?? []).length > 0 + : groupDevices.length > 0; + + const hasCohorts = + userScope === "personal" + ? (personalCohortIds ?? []).length > 0 + : (groupCohortIds ?? []).length > 0; + + const steps: string[] = []; + if (hasDevices) { + steps.push("add-device", "assign-cohort"); + } else if (hasCohorts) { + steps.push("assign-cohort"); + } + return steps; + }, [orgId, userScope, myDevicesData?.devices, groupDevices.length, personalCohortIds, groupCohortIds]); + + const visuallyCompletedSteps = React.useMemo(() => { + return Array.from(new Set([...(activeChecklistState.completedSteps || []), ...autoSteps])); + }, [activeChecklistState.completedSteps, autoSteps]); + + React.useEffect(() => { + if (autoSteps.length === 0) return; + + if (userScope === "personal") { + setLocalChecklistState((prev) => { + const merged = Array.from(new Set([...(prev.completedSteps || []), ...autoSteps])); + // Only save/re-render if something actually changed + if (merged.length === (prev.completedSteps || []).length) return prev; + const next = { ...prev, completedSteps: merged }; + saveChecklistState(orgId as string, next); + return next; + }); + } + }, [orgId, userScope, autoSteps]); + + const { mutateAsync: updateGroupOnboarding } = useUpdateGroupOnboarding(); + + const updateChecklist = React.useCallback( + async (patch: { action?: 'mark_step_complete' | 'dismiss_checklist', step_id?: string, completedSteps?: string[], dismissed?: boolean }) => { + if (userScope === "personal") { + setLocalChecklistState((prev) => { + const next = { ...prev }; + if (patch.completedSteps) next.completedSteps = patch.completedSteps; + if (patch.dismissed !== undefined) next.dismissed = patch.dismissed; + if (orgId) saveChecklistState(orgId, next); + return next; + }); + return; + } + + // Handle Organisation Scope + if (userScope === "organisation" && activeGroup?._id) { + if (!patch.action) { + if (patch.dismissed) patch.action = 'dismiss_checklist'; + else if (patch.completedSteps) { + const newestStep = patch.completedSteps[patch.completedSteps.length - 1]; + if (newestStep) { + patch.action = 'mark_step_complete'; + patch.step_id = newestStep; + } + } + } + + if (patch.action) { + try { + const missingAutoSteps = autoSteps.filter(step => + !activeChecklistState.completedSteps.includes(step) && + !(patch.action === 'mark_step_complete' && patch.step_id === step) + ); + + if (missingAutoSteps.length > 0) { + for (const step of missingAutoSteps) { + try { + await updateGroupOnboarding({ groupId: activeGroup._id, payload: { action: 'mark_step_complete', step_id: step } }); + } catch (e) { + logger.error("Failed to sync auto-step:", { error: getApiErrorMessage(e) }); + } + } + } + + const res = await updateGroupOnboarding({ groupId: activeGroup._id, payload: { action: patch.action, step_id: patch.step_id } }); + const updatedChecklist = res.data?.onboarding_checklist || res.group?.onboarding_checklist; + + if (res.success && updatedChecklist) { + dispatch(updateActiveGroupOnboarding(updatedChecklist)); + queryClient.setQueryData(['groupDetails', activeGroup._id], (old: { group?: Group } | undefined) => { + if (!old || !old.group) return old; + return { + ...old, + group: { + ...old.group, + onboarding_checklist: updatedChecklist, + } + }; + }); + } + } catch (error) { + logger.error("Failed to update onboarding checklist:", { error: getApiErrorMessage(error) }); + } + } + } + }, + [orgId, userScope, activeGroup?._id, dispatch, activeChecklistState.completedSteps, autoSteps, queryClient, updateGroupOnboarding] + ); + + const openAddDeviceChoice = React.useCallback(() => { + setIsAddDeviceChoiceOpen(true); + }, []); + + const handleGoToVisibility = React.useCallback(() => { + setAccordionItems(prev => + prev.includes("visibility") ? prev : [...prev, "visibility"] + ); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + visibilityRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + setHighlightVisibility(true); + setTimeout(() => setHighlightVisibility(false), 4000); // longer — matches coach mark + }); + }); + }, []); + + const openClaimModal = React.useCallback(() => { + if (justCompletedClaim) return; + setIsAddDeviceChoiceOpen(false); + setIsClaimModalOpen(true); + }, [justCompletedClaim]); + + const openImportModal = React.useCallback(() => { + setIsAddDeviceChoiceOpen(false); + setIsImportModalOpen(true); + }, []); + + const refreshHomeData = React.useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["devices"] }); + queryClient.invalidateQueries({ queryKey: ["myDevices"] }); + queryClient.invalidateQueries({ queryKey: ["groupCohorts"] }); + queryClient.invalidateQueries({ queryKey: ["personalUserCohorts"] }); + queryClient.invalidateQueries({ queryKey: ["cohorts"] }); + queryClient.invalidateQueries({ queryKey: ["deviceCount"] }); + }, [queryClient]); + + const handleDeviceAdded = React.useCallback( + (deviceInfo?: { deviceId?: string; deviceName?: string; cohortId?: string; isCohortImport?: boolean }) => { + setJustCompletedClaim(true); + setTimeout(() => setJustCompletedClaim(false), 1000); + refreshHomeData(); + + if (deviceInfo?.isCohortImport || deviceInfo?.cohortId) { + setNewlyClaimedDevice(undefined); + updateChecklist({ + action: 'mark_step_complete', + step_id: 'add-device', + completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "add-device", "assign-cohort"])), + }); + } else { + if (deviceInfo?.deviceId) { + setNewlyClaimedDevice([{ _id: deviceInfo.deviceId, name: deviceInfo.deviceName || "", long_name: deviceInfo.deviceName || "" }]); + } + updateChecklist({ + action: 'mark_step_complete', + step_id: 'add-device', + completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "add-device"])), + }); + } + }, + [activeChecklistState.completedSteps, refreshHomeData, updateChecklist] + ); + + const handleCohortAssigned = React.useCallback(() => { + updateChecklist({ + action: 'mark_step_complete', + step_id: 'assign-cohort', + completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "assign-cohort"])), + }); + setNewlyClaimedDevice(undefined); + }, [activeChecklistState.completedSteps, updateChecklist]); + + + + + // ── Early returns ────────────────────────────────────────────────────────── + + if (hasError) { + return ( +
+ + + {error || "Failed to load dashboard context."} + +
+ ); + } + + // ── Derived state ────────────────────────────────────────────────────────── + + const hasNoDevices = + userScope === "personal" + ? (myDevicesData?.devices ?? []).length === 0 + : groupDevices.length === 0; + + const TOTAL_STEPS = 3; // add-device, assign-cohort, set-visibility + + // The checklist stays visible as long as: + // 1. Not all steps are complete, AND + // 2. The user hasn't explicitly dismissed it + const allStepsComplete = visuallyCompletedSteps.length >= TOTAL_STEPS; + const isLoadingGroupDetailsSafe = userScope === "organisation" && isLoadingGroupDetails; + const showChecklist = !allStepsComplete && !activeChecklistState.dismissed && !isLoadingGroupDetailsSafe; + + const showClaimDevice = (() => { + switch (userContext) { + case "personal": + case "external-org": + default: + return true; + } + })(); + + const renderSharedModals = () => ( + <> + { + setIsAssignCohortModalOpen(open); + if (!open) setNewlyClaimedDevice(undefined); + }} + selectedDevices={newlyClaimedDevice as Device[]} + onSuccess={handleCohortAssigned} + title="Group your devices" + /> + + setIsClaimModalOpen(false)} + onSuccess={handleDeviceAdded} + mode={showChecklist ? "guided" : "fast"} + /> + setIsImportModalOpen(open)} + onSuccess={handleDeviceAdded} + mode={showChecklist ? "guided" : "fast"} + /> + setIsAddDeviceChoiceOpen(false)} + title="Add a device" + showFooter={false} + size="xl" + > +
+ + + +
+
+ + ); + + const isLoading = + (userScope === "personal" && isLoadingMyDevices) || + (userScope === "organisation" && isLoadingGroupDevices) || + isLoadingUserContext; + + if (isLoading) { + return ( + <> +
+
+
+ + +
+
+
+ +
+
+ +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+
+ {renderSharedModals()} + + ); + } + + const renderMainContent = () => { + if (hasNoDevices) { + return ( +
+ + {showChecklist && ( + updateChecklist({ action: 'dismiss_checklist', dismissed: true })} + onAddDevice={openAddDeviceChoice} + onGoToCohorts={() => setIsAssignCohortModalOpen(true)} + onGoToVisibility={handleGoToVisibility} + onMarkAsDone={() => {}} + organizationName={formatTitle(activeGroup?.grp_title || "")} + isReadOnly={!canClaimDevice} + /> + )} +
+ ); + } + + return ( +
+ + + {showChecklist && ( + updateChecklist({ action: 'dismiss_checklist', dismissed: true })} + onAddDevice={openAddDeviceChoice} + onGoToCohorts={() => setIsAssignCohortModalOpen(true)} + onGoToVisibility={handleGoToVisibility} + onMarkAsDone={() => {}} + organizationName={formatTitle(activeGroup?.grp_title || "")} + isReadOnly={!canClaimDevice} + /> + )} + + {showClaimDevice && ( +
+

Home

+
+ setIsClaimModalOpen(true)} + Icon={Plus} + > + Add AirQo Device + + setIsImportModalOpen(true)} + Icon={Upload} + > + Import External Device + +
+
+ )} + +
+ + + +

Device Health

+
+ + + +
+ + + +

Device Visibility

+
+ + { + const nextCompletedSteps = Array.from( + new Set([...visuallyCompletedSteps, "set-visibility"]) + ); + const justCompletedSetup = + !visuallyCompletedSteps.includes("set-visibility") && + nextCompletedSteps.length >= TOTAL_STEPS; + + updateChecklist({ + action: 'mark_step_complete', + step_id: 'set-visibility', + completedSteps: nextCompletedSteps, + }); + + if (justCompletedSetup) { + ReusableToast({ + message: "Workspace setup complete. You're ready to monitor and manage your devices.", + type: "SUCCESS", + }); + setTimeout(() => { + if (isMounted.current) { + updateChecklist({ action: 'dismiss_checklist', dismissed: true }); + } + }, 2000); + } + }} + /> + +
+
+
+ + {userId && (user?.email || user?.userName) && ( + + )} +
+ ); + }; + + return ( + <> + {renderMainContent()} + {renderSharedModals()} + + ); +}; + +export default WelcomePage; diff --git a/src/vertex-template/app/(authenticated)/layout.tsx b/src/vertex-template/app/(authenticated)/layout.tsx new file mode 100644 index 0000000000..34d3c5f3c6 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/layout.tsx @@ -0,0 +1,29 @@ +"use client"; + +import Layout from "@/components/layout/layout"; +import { ForbiddenGuard } from "@/components/layout/accessConfig/forbidden-guard"; +import { useForbiddenHandler } from "@/core/hooks/useForbiddenHandler"; +import { useContextAwareRouting } from "@/core/hooks/useContextAwareRouting"; +import { PageTitleProvider } from "@/context/page-title-context"; + +export default function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + // Listen for forbidden events + useForbiddenHandler(); + + // Handle context-aware routing + useContextAwareRouting(); + + return ( + + + + {children} + + + + ); +} diff --git a/src/vertex-template/app/(authenticated)/sites/[id]/page.tsx b/src/vertex-template/app/(authenticated)/sites/[id]/page.tsx new file mode 100644 index 0000000000..39bea20b74 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/sites/[id]/page.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AqArrowLeft } from "@airqo/icons-react"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { useSiteDetails, useRefreshSiteMetadata } from "@/core/hooks/useSites"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { useParams } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { SiteInformationCard } from "@/components/features/sites/site-information-card"; +import { SiteMobileAppCard } from "@/components/features/sites/site-mobile-app-card"; +import { EditSiteDetailsDialog } from "@/components/features/sites/edit-site-details-dialog"; +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; +import SiteMeasurementsApiCard from "@/components/features/sites/site-measurements-api-card"; +import SiteActivityCard from "@/components/features/sites/site-activity-card"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { usePageTitle } from "@/context/page-title-context"; + +const ContentGridSkeleton = () => ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+); + +export default function UserSiteDetailsPage() { + const params = useParams(); + const siteId = params.id as string; + const { data: site, isLoading, error } = useSiteDetails(siteId); + const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata(); + const router = useRouter(); + const [editSection, setEditSection] = useState<"general" | "mobile" | null>( + null + ); + usePageTitle({ title: site?.name || "Site Details", section: "Sites" }); + + return ( + +
+
+ router.back()} + Icon={AqArrowLeft} + > + Back + + + refreshMetadata(siteId)} + disabled={isRefreshing || isLoading || !site} + loading={isRefreshing} + Icon={RefreshCw} + className="text-xs font-medium" + > + Refresh Metadata + +
+ + {error ? ( +
+ + + Error + + Unable to load site details. Please try again. + + +
+ ) : isLoading ? ( + + ) : !site ? ( +
+ Site not found. +
+ ) : ( + <> +
+
+ setEditSection("general")} + /> +
+ +
+ setEditSection("mobile")} + /> + +
+ +
+ +
+
+
+ { + router.push(`/devices/overview/${device._id}`); + }} + /> +
+ !open && setEditSection(null)} + site={site} + section={editSection || "general"} + /> + + )} +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/sites/overview/page.tsx b/src/vertex-template/app/(authenticated)/sites/overview/page.tsx new file mode 100644 index 0000000000..121b18edcf --- /dev/null +++ b/src/vertex-template/app/(authenticated)/sites/overview/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import SitesTable from "@/components/features/sites/sites-list-table"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function SitesOverviewPage() { + return ( + +
+
+
+

Sites

+

+ Manage and organize your monitoring sites +

+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md b/src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md new file mode 100644 index 0000000000..551b609522 --- /dev/null +++ b/src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md @@ -0,0 +1,193 @@ +> **NOTE**: This document describes the specific solution for Private Context permissions. For the complete system architecture regarding Scopes and Contexts, please refer to [ACCESS-CONTROL-ARCHITECTURE.md](../internal/ACCESS-CONTROL-ARCHITECTURE.md). + +# Using AirQo Group Permissions in Private Context + +## Problem Statement +Every user has an AirQo group assigned to them. The app has different contexts (personal, airqo-internal, external-org) with different view states. However, in the **private context**, we needed to use the permissions assigned to the user's AirQo group for certain use cases, instead of hardcoding or skipping permission checks. + +## Solution Overview + +We implemented a fallback mechanism that: +1. Checks if the user is in `personal` context +2. Retrieves their AirQo group (if they have one) +3. Uses that group's permissions for permission checks + +## Implementation Details + +### 1. Permission Service (`core/permissions/permissionService.ts`) + +Added a new method to retrieve the user's AirQo group: + +```typescript +/** + * Get user's AirQo group (if they have one) + * This is useful for private context permission checks + */ +getAirQoGroup(user: UserDetails): Group | undefined { + if (!user.groups) return undefined; + + return user.groups.find((group) => + group.grp_title.toLowerCase() === 'airqo' + ); +} +``` + +This method: +- Searches through the user's groups +- Finds the group with title "airqo" (case-insensitive) +- Returns the group object or undefined if not found + +### 2. usePermission Hook (`core/hooks/usePermissions.ts`) + +Modified to use AirQo group permissions as fallback in personal context: + +```typescript +export const usePermission = (permission: Permission, context?: Partial) => { + const user = useAppSelector((state) => state.user.userDetails); + const activeGroup = useAppSelector((state) => state.user.activeGroup); + const activeNetwork = useAppSelector((state) => state.user.activeNetwork); + const userContext = useAppSelector((state) => state.user.userContext); + + const result = useMemo(() => { + if (MOCK_PERMISSIONS_ENABLED) { + return MOCK_PERMISSIONS[permission] ?? false; + } + + if (!user) return false; + + // If in personal context and no active organization is provided, + // check against AirQo group permissions (if user has one) + let effectiveContext = context; + if (userContext === 'personal' && !context?.activeOrganization && !activeGroup) { + const airqoGroup = permissionService.getAirQoGroup(user); + if (airqoGroup) { + effectiveContext = { + ...context, + activeOrganization: airqoGroup, + }; + } + } + + return permissionService.hasPermission(user, permission, { + ...effectiveContext, // ✅ Spread first + activeOrganization: effectiveContext?.activeOrganization ?? activeGroup ?? undefined, + activeNetwork: effectiveContext?.activeNetwork ?? activeNetwork ?? undefined, + }); + }, [user, permission, activeGroup, activeNetwork, userContext, context]); + + return result; +}; +``` + +Key changes: +- Added `userContext` to the hook's dependencies +- Check if we're in `personal` context without an active group +- If so, retrieve the AirQo group and use it as the `activeOrganization` +- This ensures permission checks use the AirQo group's role permissions + +### 3. useUserContext Hook (`core/hooks/useUserContext.ts`) + +Updated `getContextPermissions()` to use actual permission checks in personal context: + +```typescript +const getContextPermissions = () => { + // In personal context, check AirQo group permissions if they exist + if (userContext === 'personal') { + // Device view is always available in personal context for owned devices + // But for other permissions, check the user's AirQo group permissions + return { + canViewDevices: true, // Always true for personal devices + canViewSites, + canViewUserManagement, + canViewAccessControl, + canViewOrganizations: false, + canViewNetworks: false, + }; + } + + // For organizational contexts, use the regular permission checks + return { + canViewDevices, + canViewSites, + canViewUserManagement, + canViewAccessControl, + canViewNetworks, + }; +}; +``` + +Changes: +- Instead of hardcoding `false` for all permissions except `canViewDevices` +- Now uses the actual permission hooks (`canViewSites`, `canViewUserManagement`, etc.) +- These will automatically use AirQo group permissions thanks to the `usePermission` hook changes + +## How It Works + +### Scenario 1: User in Personal Context WITH AirQo Group + +1. User is in `personal` context +2. No `activeGroup` is set (personal mode) +3. User calls `usePermission(PERMISSIONS.SITE.VIEW)` +4. Hook detects personal context without active group +5. Retrieves user's AirQo group using `permissionService.getAirQoGroup(user)` +6. Uses AirQo group as the `activeOrganization` for permission check +7. Permission service checks if the user's AirQo group role has `SITE.VIEW` permission +8. Returns `true` or `false` based on actual permissions + +### Scenario 2: User in Personal Context WITHOUT AirQo Group + +1. User is in `personal` context +2. No `activeGroup` is set +3. User has no AirQo group +4. Permission check falls through to default behavior +5. Returns `false` (no permissions without a group) + +### Scenario 3: User in Organizational Context + +1. User is in `airqo-internal` or `external-org` context +2. `activeGroup` is set +3. Permission checks use the `activeGroup` directly +4. No AirQo group fallback needed + +## Benefits + +1. **Consistent Permission Model**: Same RBAC system works across all contexts +2. **Flexibility**: Users can have different permissions in their AirQo group that apply to personal context +3. **No Hardcoding**: Permissions are data-driven, not hardcoded in the UI +4. **Backward Compatible**: Doesn't break existing behavior for organizational contexts +5. **Fine-Grained Control**: Admins can control what users can do in personal context via AirQo group roles + +## Use Cases + +This implementation enables scenarios like: + +- **AirQo staff** with appropriate permissions can view sites even in personal mode +- **Admin users** can access user management features from personal context +- **Technical staff** can perform maintenance operations regardless of context +- **Analysts** can access data export features in personal mode + +## Testing + +To test this implementation: + +1. **Create a user with AirQo group** that has specific permissions (e.g., `SITE.VIEW`) +2. **Switch to personal context** +3. **Check if permission-gated features** are accessible based on AirQo group permissions +4. **Verify permission checks** return correct values + +Example: +```typescript +// In a component +const canViewSites = usePermission(PERMISSIONS.SITE.VIEW); +// In personal context, this will check the user's AirQo group permissions + +// In a feature +{canViewSites && } +// This button will show in personal mode if the user's AirQo group has SITE.VIEW +``` + +## Notes + +- The `canViewDevices` permission is always `true` in personal context because users should always see their own devices +- Organizations-specific features (`canViewOrganizations`, `canViewNetworks`) remain `false` in personal context as they don't make sense there +- The implementation preserves all existing behavior for `airqo-internal` and `external-org` contexts diff --git a/src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md b/src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md new file mode 100644 index 0000000000..fcea6f4919 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md @@ -0,0 +1,79 @@ +# Access Control Architecture: Scopes & Contexts + +> **Note**: This document describes the current access control model, focusing on the decoupling of Context and Scope. + +## Core Concepts + +The application uses a **Scope-based Architecture** to decouple *where a user is* (Context) from *how the application behaves* (Scope). + +### 1. Context (`userContext`) +Represents the user's current "location" or logical grouping. +- **`personal`**: The default view for users. This encompasses both "normal" users managing their own devices AND AirQo staff managing the system (via their personal workspace). +- **`external-org`**: Specific to users belonging to third-party organizations (e.g., KCCA, NEMA) when they have explicitly switched to that organization's view. + +### 2. Scope (`userScope`) +Represents the *behavioral mode* of the application. This determines navigation, available features, and data visibility. +- **`personal` Scope**: Focuses on "My Resources". Shows personal devices, user-specific data, and for AirQo staff, allows system management. +- **`organisation` Scope**: Focuses on "Shared Resources". Shows organization-wide dashboards, all devices in the org, team management. This is strictly for External Organizations. + +## The Model: Context → Scope Mapping + +We enforce strict scoping rules to ensure consistent user experience: + +| Context | Mapped Scope | Behavior Description | +| :--- | :--- | :--- | +| `personal` | `personal` | **"My Workspace"**. User manages their own devices, follows their own sites. AirQo staff also perform system admin here. | +| `external-org` | `organisation` | **"Organization Dashboard"**. Traditional admin view for external partners to manage their specific entity. | + +> **Key Architecture Note**: We previously had an `airqo-internal` context, but this has been deprecated. AirQo group members now operate strictly within the `personal` context. They do not "administer the AirQo Organization" like an external org admin; instead, they administrate the *System* from their *Personal Workspace*. + +## Permission Resolution Strategy + +How we decide if a user can do something (`usePermission` hook) depends on the Scope. + +### 1. In `organisation` Scope (External Orgs) +* **Source**: The `activeGroup` (the organization the user is currently viewing). +* **Logic**: Standard RBAC. Does the user's role *in this specific group* have the permission? +* **Example**: A KCCA Admin viewing KCCA dashboard checks KCCA group permissions. + +### 2. In `personal` Scope (Personal Workspace) +This uses a layered approach to allow seamless system management. + +* **Layer 1: Ownership (Implicit)** + * Users always have owner-level access to their *own* resources (e.g., `canViewDevices` is always true for own devices). + +* **Layer 2: Active Group Assignment (Universal)** + * **Problem**: Users in Personal Mode need permissions to access certain features (like Admin tools), but traditionally had no active group. + * **Solution**: If a user belongs to the **AirQo** group, the system now automatically sets it as their `activeGroup` even when in Personal Mode. + * **Result**: Standard permissions work out-of-the-box. An Admin sees their admin features because they genuinely have the AirQo group active. + +### Implementation Details + +#### `userSlice.ts` +Logic defining the active group assignment: +```typescript +if (!action.payload) { + // Personal Mode + // If user belongs to AirQo group, keep it active! + const airqoGroup = state.userGroups.find((g) => g.grp_title === 'airqo'); + if (airqoGroup) { + state.activeGroup = airqoGroup; + // ... + } +} +``` + +## Matrix: Who Sees What Where? + +| Feature | AirQo Staff (Personal Context) | External Org Admin (Org Context) | +| :--- | :--- | :--- | +| **Scope** | `personal` | `organisation` | +| **My Devices** | ✅ Visible | ❌ Hidden | +| **Org Dashboard** | ❌ Hidden | ✅ Visible | +| **Network Mgmt** | ✅ Visible (via AirQo Group) | ❌ Hidden | +| **Site Mgmt** | ✅ Visible (via AirQo Group) | ✅ Visible (Org sites only) | + +## Summary +* **External Users**: Strict isolation in `external-org` context. They only see what's in their Org. +* **AirQo Staff**: Exist in `personal` context. They "live" in a Personal Scope but "borrow" powers from the AirQo System Group to manage global resources like Networks and Sites. +* **Context Switching**: Available to any user who belongs to multiple organizations. diff --git a/src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md b/src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md new file mode 100644 index 0000000000..ecc2771ed1 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md @@ -0,0 +1,104 @@ +# AirQo RBAC Feature Access – Living Document + +--- + +## RBAC Principles + +- **Transparency:** Prefer disabling over hiding, unless security or irrelevance dictates otherwise. +- **Security:** Hide destructive or sensitive actions from all but the highest-level admins. +- **Motivation:** Disabled features should explain what's required to enable them. +- **Clarity:** Users should never wonder if something is missing due to a bug. + +--- + +## Feature Access Matrix + +| **Feature/Action** | **Super Admin** | **Org Admin** | **Technician** | **Analyst** | **Developer** | **Viewer** | **Guest/No Org** | **Disable for** | **Hide for** | +|----------------------------|:--------------:|:-------------:|:--------------:|:-----------:|:-------------:|:----------:|:----------------:|:-------------------------------|:------------------------------------| +| Device Claim | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org | +| Device Deploy | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org | +| Device Update | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org | +| Device Delete | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Device View | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org | +| Device Assignment/Share | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Site Create/Update | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org | +| Site Delete | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Site View | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org | +| Analytics Dashboard | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org | +| Data Export | ✔️ | ✔️ | ❌ | ✔️ | ✔️ | ❌ | ❌ | All except Super/Org Admin/Analyst/Dev | Guest/No Org, Technician, Viewer | +| User Management | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Role Management | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Organization Management | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super Admin | All except Super Admin | +| Settings Edit | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Settings View | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org | +| Premium/Upgrade | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | All except premium orgs/users | Guest/No Org, non-premium orgs | +| Destructive Actions | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super Admin | All except Super Admin | + +**Legend:** +- ✔️ = Has access (feature enabled) +- ❌ = No access (feature disabled or hidden) +- **Disable for** = Show feature, but disabled, for these roles +- **Hide for** = Don't show feature at all for these roles + +--- + +## Sidebar & Application Feature Mapping + +### Sidebar Structure (Example) + +| **Sidebar Item** | **Sub-Items** | **Permission** | **Disable for** | **Hide for** | +|-------------------------|------------------------------|-------------------------------|--------------------------------|--------------------------------------| +| Dashboard | — | DEVICE.VIEW | — | Guest/No Org | +| Network Map | — | SITE.VIEW | — | Guest/No Org | +| Devices | Overview, My Devices | DEVICE.VIEW | All except permitted roles | Guest/No Org | +| | Deploy, Claim, Share | DEVICE.DEPLOY/UPDATE/ASSIGN | All except permitted roles | Guest/No Org, Viewer | +| Sites | Overview, Create, Grids | SITE.VIEW, SITE.CREATE | All except permitted roles | Guest/No Org | +| Cohorts | Overview, Create | DEVICE.VIEW, DEVICE.UPDATE | All except permitted roles | Guest/No Org | +| User Management | — | USER.VIEW, USER.MANAGEMENT | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Access Control | Roles, Permissions | ROLE.VIEW, ROLE.MANAGEMENT | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Organizations | — | ORGANIZATION.VIEW | All except Super Admin | All except Super Admin | +| Team | Overview, Invite, Roles | USER.VIEW, USER.CREATE, ROLE.VIEW | All except permitted roles | Guest/No Org, Technician, Analyst | +| Profile Settings | — | — | — | — | + +**Notes:** +- For each sidebar item, use your `PermissionGuard` or `usePermission` to determine if it should be enabled or disabled. +- If a user is in the "Hide for" group, do not render the sidebar item at all. +- If a user is in the "Disable for" group, render the item but make it non-clickable and show a tooltip explaining why. + +--- + +### Application Features (Major Screens/Actions) + +| **Feature/Screen** | **Permission** | **Disable for** | **Hide for** | +|----------------------------|-------------------------------|--------------------------------|--------------------------------------| +| Claim Device | DEVICE.CLAIM | All except permitted roles | Guest/No Org | +| Deploy Device | DEVICE.DEPLOY | All except permitted roles | Guest/No Org | +| Device Assignment/Share | DEVICE.ASSIGN | All except permitted roles | Guest/No Org, Viewer | +| Delete Device | DEVICE.DELETE | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Create Site | SITE.CREATE | All except permitted roles | Guest/No Org | +| Delete Site | SITE.DELETE | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Export Data | ANALYTICS.DATA_EXPORT | All except Analyst/Dev/Admin | Guest/No Org, Technician, Viewer | +| Invite User | USER.INVITE | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Edit Roles | ROLE.EDIT | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Organization Management | ORGANIZATION.UPDATE | All except Super Admin | All except Super Admin | +| Edit Settings | SETTINGS.EDIT | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| View Settings | SETTINGS.VIEW | All except permitted roles | Guest/No Org | +| Destructive Actions | SYSTEM.SUPER_ADMIN | All except Super Admin | All except Super Admin | + +--- + +## How to Maintain This Document + +- **Update** whenever new features or roles are added. +- **Review** with product/design quarterly or after major releases. +- **Link** to this doc from engineering and product wikis. +- **Add** a "Request Access" or "Upgrade" workflow for disabled features as needed. + +--- + +## Future Updates / Open Questions + +- [ ] Are there new roles or custom roles to consider? +- [ ] Should some features be "requestable" (user can click to request access)? +- [ ] Are there features that should be hidden for compliance/security reasons? +- [ ] Should we add analytics to track how often users see disabled features? \ No newline at end of file diff --git a/src/vertex-template/app/_docs/internal/device-status.md b/src/vertex-template/app/_docs/internal/device-status.md new file mode 100644 index 0000000000..570832d312 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/device-status.md @@ -0,0 +1,36 @@ +# Vertex Device Status Guide + +This document defines the current logic used to determine a device's status in Vertex. + +## Core Concept + +Our logic is based on two independent boolean fields from the API: + +### 1. rawOnlineStatus (Raw Data) + +- **What it means:** Is the device sending raw, unprocessed data to the cloud? +- **Values:** Transmitting (true) / Not Transmitting (false) + +### 2. isOnline (Calibrated Data) + +- **What it means:** Is processed, calibrated data available for use? +- **Values:** Data Ready (true) / Processing or No Data (false) + +These fields are independent. For example, a device can be transmitting raw data (`rawOnlineStatus: true`) while the calibrated data is still processing (`isOnline: false`). + +## Status Definitions + +The combination of these two fields determines the device's primary status: + +| Status | Color | rawOnlineStatus (Raw Data) | isOnline (Calibrated Data) | Logic Summary | +|--------|-------|----------------------------|----------------------------|---------------| +| **Operational** | Green | true (Transmitting) | true (Data Ready) | Device transmitting • Data ready for use. | +| **Transmitting** | Blue | true (Transmitting) | false (Processing) | Receiving data • Processing calibration | +| **Data Available** | Yellow | false (Not Transmitting) | true (Data Ready) | Using recent data • Not currently transmitting. | +| **Not Transmitting** | Gray | false (Not Transmitting) | false (Processing) | No recent data from the device. | + +## Special Case: Invalid Date + +| Status | Color | Logic | +|--------|-------|-------| +| **Invalid Date** | Purple | This status appears if the device reports a `lastActive` timestamp that is more than 5 minutes in the future. This indicates a device-level clock or configuration error and overrides the logic above. | diff --git a/src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md b/src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md new file mode 100644 index 0000000000..5384e7e88c --- /dev/null +++ b/src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md @@ -0,0 +1,43 @@ +# Vertex Adapter Foundation + +## Purpose + +This document describes the adapter foundation for `create-vertex-app` v1. The foundation defines the interface that future app data flows should use and provides a mock adapter that can power local evaluation without API credentials. + +## Files + +- `core/adapters/types.ts`: v1 adapter contract. +- `core/adapters/index.ts`: adapter factory selected by `vertex.config.ts`. +- `core/adapters/mock.ts`: mock adapter implementation. +- `core/adapters/mock-fixtures.ts`: typed fixture data. +- `core/adapters/airqo.ts`: AirQo wrapper around the existing service layer. + +## V1 Adapter Choices + +- `mock`: implemented now and intended as the generated template default. +- `airqo`: wraps existing `core/apis/*` services without rewriting endpoint behavior. + +Generic REST remains out of scope for v1. + +## Integration Boundary + +This foundation does not refactor existing React Query hooks yet. The next integration step should route `core/hooks/*` through the adapter while keeping hook return shapes stable for current components. + +## Mock Adapter Coverage + +The mock adapter includes: + +- Seeded user, group, role, and network data. +- Four devices covering online, offline, private, deployed, and undeployed states. +- Three sites with coordinates and status variety. +- Two cohorts with device membership. +- Device and site activity events. +- Latest and historical reading data. +- Successful no-op mutations for claim, deploy, recall, create, update, maintenance, and group assignment flows. + +## Contributor Guidance + +- Keep fixture data realistic but small. +- Add adapter methods only when current v1 screens need them. +- Keep AirQo endpoint mapping out of `mock.ts`; `airqo.ts` owns live API service mapping. +- Do not add REST adapter behavior in v1. diff --git a/src/vertex-template/app/_docs/internal/vertex-template-config-contract.md b/src/vertex-template/app/_docs/internal/vertex-template-config-contract.md new file mode 100644 index 0000000000..8a7c2fe7a2 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/vertex-template-config-contract.md @@ -0,0 +1,47 @@ +# Vertex Template Config Contract + +## Purpose + +`vertex.config.ts` is the single deployer-owned configuration file for a Vertex instance. The future `create-vertex-app` CLI should generate this file from wizard answers. + +Shared validation, defaults, and TypeScript types live in `core/config/vertex-config.ts`. + +## V1 Defaults + +V1 supports only: + +- `api.adapter: "mock"` for local evaluation without credentials. +- `api.adapter: "airqo"` for the current AirQo API-backed app. +- `auth.provider: "none"` for mock/local template evaluation. +- `auth.provider: "airqo"` for the existing AirQo auth/session flow. + +Generic REST adapters, plugin systems, deploy wizards, and config admin screens are v2 work. + +## Required Config Groups + +- `org`: visible organization identity, logo, primary color, support email, website, and slug. +- `api`: adapter choice, AirQo API base URL when applicable, and public measurement URL base. +- `auth`: auth provider and system group slug used by AirQo-mode permission logic. +- `features`: v1 feature flags for maps, bulk deploy, sites, CSV export, readings, user management, desktop download, app launcher, shipping, and network requests. +- `map`: default center, zoom, and tile provider. +- `links`: docs, privacy, cookie policy, analytics, and desktop download URLs. + +## Validation Rules + +- Organization name, short name, slug, logo, primary color, and support email must be valid. +- `org.primaryColor` must be a hex color. +- `api.adapter` must be `mock` or `airqo`. +- `auth.provider` must be `none` or `airqo`. +- `api.baseUrl` is required when `api.adapter` is `airqo`. +- `auth.provider: "airqo"` requires `api.adapter: "airqo"`. +- Map latitude must be between -90 and 90. +- Map longitude must be between -180 and 180. +- Map zoom must be between 0 and 22. + +## Contributor Guidance + +- Do not add new top-level config groups without maintainer approval. +- Prefer adding feature-specific options under an existing group. +- Keep `vertex.config.example.ts` mock-first. +- Keep `vertex.config.ts` AirQo-compatible until template extraction is complete. +- Do not implement the generic REST adapter in v1. diff --git a/src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md b/src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md new file mode 100644 index 0000000000..dfeac094d5 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md @@ -0,0 +1,137 @@ +# Vertex Template Coupling Audit + +## Purpose + +This audit maps the current Vertex app coupling that must be addressed before extracting a reusable `vertex-template` and publishing `create-vertex-app`. + +The current app is a Next.js App Router project. Routes live in `app/`, feature UI in `components/features/`, layout in `components/layout/`, API services in `core/apis/`, React Query hooks in `core/hooks/`, auth/proxy routes in `app/api/`, and shared state/config in `core/`, `context`, and `lib`. + +## Summary + +V1 should not rewrite Vertex. It should isolate existing behavior behind configuration and adapters. + +Primary coupling areas: + +- AirQo branding in metadata, layout, login, errors, download, footer, topbar, title bar, app launcher, and support links. +- AirQo API access through `NEXT_PUBLIC_API_URL`, proxy routes, `secureApiProxyClient`, and `core/apis/*`. +- AirQo group/network assumptions in auth, Redux user state, permissions, hooks, and device/site filtering. +- Hardcoded public measurement endpoint examples. +- Map defaults and Mapbox-only assumptions. +- Optional AirQo/ops features that should be feature-flagged or excluded from v1 template defaults. + +V1 adapter support should be `mock` and `airqo` only. Generic REST remains v2. + +## Priority Legend + +- `P0`: Required before template extraction. +- `P1`: Required before public v1 release, can follow adapter foundation. +- `P2`: Can remain in AirQo mode or be deferred if hidden by feature flags. +- `Keep`: Intentional dependency or acceptable package/library usage. + +## Coupling Inventory + +| Area | Files | Current coupling | Replacement | Priority | +|---|---|---|---|---| +| App metadata | `app/layout.tsx` | Hardcoded `AirQo Vertex`, AirQo description, `vertex.airqo.net`, AirQo image path | Read org/app metadata from `vertexConfig.org` and template defaults | P0 | +| Page title provider | `context/page-title-context.tsx` | `APP_TITLE = "AirQo Vertex"` | Use configured app/org title | P0 | +| Login branding | `app/login/page.tsx` | AirQo logo and copy about AirQo open data channels | Use configured logo, org name, support/value copy | P0 | +| Auth error page | `app/auth-error/page.tsx` | AirQo logo, `support@airqo.net`, AirQo copyright | Use config support email, logo, org name | P0 | +| Topbar | `components/layout/topbar.tsx` | AirQo logo, account labels | Use configured logo and neutral account labels | P0 | +| Primary sidebar | `components/layout/primary-sidebar.tsx` | AirQo logo, Vertex label, AirQo admin role names | Logo from config; admin visibility from config and permission mode | P1 | +| Desktop titlebar | `components/layout/desktop-titlebar.tsx` | AirQo logo fallback and `AirQo Vertex` text | Use config branding and generic fallback | P1 | +| Footer | `components/layout/Footer.tsx` | AirQo copyright and platform name | Config org name and app name | P1 | +| App launcher | `components/layout/AppDropdown.tsx` | AirQo ecosystem links, AirQo app store copy | Hide by default or make AirQo-mode-only feature | P2 | +| Download page | `app/download/page.tsx`, `components/features/download/*`, `core/constants/app-downloads.ts` | AirQo Vertex Desktop release URL/copy | Feature-flag desktop download; config-driven URL if enabled | P2 | +| Cookie banner | `components/features/auth/cookie-info-banner.tsx`, `lib/envConstants.ts` | AirQo cookie copy and default AirQo policy URL | Configurable policy URL and org name | P1 | +| Feedback | `components/features/feedback/*` | Event key `airqo:feedback:open`, "Send feedback to AirQo" | Configurable org name; event key can be generic | P1 | +| Public support links | `app/not-found.tsx`, `components/ui/permission-tooltip.tsx` | AirQo support/docs links | Configurable support and docs URLs | P1 | +| Theme color | `app/globals.css`, `tailwind.config.ts` | Primary blue is hardcoded in CSS variables | Inject/configure `org.primaryColor`; keep Tailwind variable pattern | P0 | +| API base URLs | `lib/envConstants.ts`, `core/urls.tsx`, `core/config/proxyConfig.ts` | Requires `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_API_TOKEN`; Analytics default is AirQo | Move API base URL/token requirements behind adapter mode | P0 | +| Axios/proxy client | `core/utils/secureApiProxyClient.ts`, `core/utils/proxyClient.ts`, `core/apis/axiosConfig.ts` | AirQo proxy behavior and auth headers | Keep for AirQo adapter; mock adapter bypasses it | P0 | +| Dynamic proxy route | `app/api/[...path]/route.ts` | Proxies to configured AirQo-style backend path | Keep AirQo-mode only; avoid requiring in mock mode | P1 | +| Devices service | `core/apis/devices.ts` | Direct AirQo endpoint paths | Wrap via AirQo adapter; do not rewrite endpoints in v1 | P0 | +| Sites service | `core/apis/sites.ts` | Direct AirQo endpoint paths | Wrap via AirQo adapter | P0 | +| Cohorts service | `core/apis/cohorts.ts` | Direct AirQo endpoint paths | Wrap needed methods via adapter | P0 | +| Networks service | `core/apis/networks.ts`, `core/services/network-service.ts` | AirQo users/networks endpoints and `ADMIN_SECRET` flows | AirQo adapter or AirQo-mode-only admin feature | P1 | +| Grids service | `core/apis/grids.ts` | Direct backend paths | Include only if grid feature remains enabled; otherwise flag | P2 | +| Users/auth service | `core/apis/users.ts`, `app/api/auth/[...nextauth]/*` | AirQo login/profile/session shape | Keep for AirQo mode; mock/no-auth path needed for template evaluation | P0 | +| Cloudinary | `core/apis/cloudinary.ts`, `app/api/cloudinary/upload/route.ts` | Requires Cloudinary env vars | Feature-flag uploads or make optional config | P2 | +| Slack logging | `app/api/log-to-slack/route.ts`, `lib/logger.ts` | Slack webhook route | Keep optional; disabled when env missing | P2 | +| Mapbox | `components/features/mini-map/mini-map.tsx`, `components/features/location-autocomplete/LocationAutocomplete.tsx` | Mapbox token, Mapbox geocoding, Kampala default center | Config map provider, default center/zoom, optional Mapbox token | P1 | +| Measurement examples | `components/features/devices/device-measurements-api-card.tsx`, `components/features/sites/site-measurements-api-card.tsx`, `components/features/cohorts/cohort-measurements-api-card.tsx`, `components/features/grids/grid-measurements-api-card.tsx` | Hardcoded `https://api.airqo.net/api/v2/...` examples | Build URLs from config/API docs base; hide in mock mode if misleading | P1 | +| Auth default org | `core/auth/authProvider.tsx` | Prefers group named `airqo` as default organization | Adapter/config-defined system group or AirQo-mode-only logic | P0 | +| Redux user context | `core/redux/slices/userSlice.ts` | Treats `airqo` group as personal/elevated context | Abstract into configured system group or mock local context | P0 | +| Permissions | `core/permissions/*`, `core/hooks/usePermissions.ts` | AirQo RBAC names and AirQo group fallback | Keep AirQo mode; add simple mock/no-auth permissions for v1 evaluation | P0 | +| Device hooks | `core/hooks/useDevices.ts` | Imports `devices` API directly and checks `airqo` group | Route through adapter-backed hook layer | P0 | +| Site hooks | `core/hooks/useSites.ts` | Imports `sites` API directly and checks `airqo` group | Route through adapter-backed hook layer | P0 | +| Network hooks | `core/hooks/useNetworks.ts` | Sorts `airqo` network first; imports APIs directly | Adapter-backed data and config-defined preferred network | P1 | +| User context hook | `core/hooks/useUserContext.ts` | AirQo-specific personal/org scope comments and assumptions | Configurable auth/permission mode | P1 | +| Device claim/import | `components/features/claim/*`, `components/features/devices/import-device-modal.tsx` | Claim AirQo Device copy, reserved `airqo` cohort, AirQo device placeholders | Configurable device vocabulary; reserved names AirQo-mode only | P1 | +| Device deploy | `components/features/devices/deploy-device-component.tsx` | Direct fetch to `/api/v2/devices/my-devices?claim_status=claimed`; default network `airqo` | Move data call into hook/adapter; default network from config/current context | P0 | +| Device create/admin | `components/features/devices/create-device-modal.tsx`, `app/(authenticated)/admin/networks/[id]/page.tsx` | "Add AirQo Device" copy and AirQo network branch | Configurable copy; AirQo branch only in AirQo mode | P1 | +| Public visibility copy | `components/features/home/network-visibility-card.tsx`, `components/features/cohorts/cohort-detail-card.tsx` | "AirQo Map" copy | Configurable public map/product name | P1 | +| Empty states | `components/features/home/HomeEmptyState.tsx`, `components/features/cohorts/cohorts-empty-state.tsx` | AirQo device copy | Configurable device terminology | P1 | +| Shipping labels | `components/features/shipping/*` | AirQo Air Quality Monitor label text | AirQo-mode only or config-driven label brand | P2 | +| Network request flows | `components/features/networks/*`, `app/api/network/route.ts`, `app/api/devices/network-creation-requests/*` | AirQo backend admin-secret network creation | AirQo-mode admin feature; not required for mock default | P2 | +| Query/cache keys | `core/providers/query-provider.tsx`, `core/utils/clientCache.ts` | `airqo:vertex:*` storage keys | Generic `vertex:*` or config namespace to avoid cross-instance collisions | P1 | +| Internal docs/changelog | `app/changelog.md`, `app/_docs/internal/*`, `app/_docs/deprecated/*` | AirQo internal history and architecture | Exclude or heavily sanitize from `vertex-template` | P2 | + +## Direct API Call Notes + +Most backend access is already centralized, which is good for adapter extraction: + +- `core/apis/devices.ts` +- `core/apis/sites.ts` +- `core/apis/cohorts.ts` +- `core/apis/grids.ts` +- `core/apis/networks.ts` +- `core/apis/users.ts` +- `core/apis/roles.ts` +- `core/apis/permissions.ts` +- `core/apis/organizations.ts` + +The main component-level exceptions found are: + +- `components/features/devices/deploy-device-component.tsx`: direct `fetch("/api/v2/devices/my-devices?claim_status=claimed")` +- `components/features/networks/create-network-form.tsx`: posts to `/api/network` +- `components/features/networks/network-request-dialog.tsx`: posts to `/api/devices/network-creation-requests` +- `components/features/mini-map/mini-map.tsx`: fetches Mapbox reverse geocoding +- `components/features/location-autocomplete/LocationAutocomplete.tsx`: fetches Mapbox suggestions +- Measurement API card components hardcode public AirQo API examples. + +## Recommended Migration Order + +1. Add typed config and defaults. +2. Add adapter interface and mock adapter. +3. Add AirQo adapter wrapping existing `core/apis`. +4. Refactor `core/hooks` to use adapter-backed access. +5. Remove component-level direct backend calls or move them behind hooks/adapters. +6. Config-drive branding, metadata, theme, map defaults, support links, and feature flags. +7. Hide or AirQo-mode-gate admin/network/shipping/download/app-launcher features. +8. Sanitize docs and extract template. + +## Out of Scope For V1 + +- Generic REST adapter. +- Production deploy wizard. +- Plugin system. +- Managed hosting. +- Template upgrade command. +- Admin UI for config. +- Replacing `@airqo/icons-react` solely because of package name; icon imports can stay unless brand/legal review requires removal. + +## Open Questions For Maintainers + +- Should `@airqo/icons-react` remain a public dependency in the generic template? +- Should desktop download and shipping workflows ship disabled by default or be excluded from template v1? +- What is the approved default mock organization name, logo, and primary color? +- Should mock mode use `auth.provider = "none"` with a synthetic local user, or keep NextAuth with seeded credentials? +- Should public measurement API cards appear in mock mode, or only in AirQo mode? + +## Contributor Guidance + +This audit is documentation only. Follow-up implementation tasks should avoid broad file ownership conflicts: + +- Core maintainers should own config, adapter contracts, auth, and hook refactors. +- Open-source contributors can safely work on branding copy, measurement cards, docs, fixture data, and feature-flagged UI after the config contract lands. +- PRs should touch narrow areas and avoid project-wide formatting. diff --git a/src/vertex-template/app/api/[...path]/route.ts b/src/vertex-template/app/api/[...path]/route.ts new file mode 100644 index 0000000000..d923ddff56 --- /dev/null +++ b/src/vertex-template/app/api/[...path]/route.ts @@ -0,0 +1,48 @@ + +import { createProxyHandler } from '@/core/utils/proxyClient'; +import { getAuthOptions } from '@/core/config/proxyConfig'; +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Dynamic API proxy handler + * + * This file handles all API proxy requests by forwarding them to the actual API + * but without exposing sensitive tokens on the client side. + * + * For routes that need API_TOKEN authentication, the token is added on the server side. + * For routes requiring JWT auth, the authorization header is forwarded from the client. + * + * Path pattern: /api/[...path] + */ + +interface RouteParams { + params: { + path: string[]; + }; +} + +// Determine if the request needs authentication based on the path or explicit header +const handler = (request: NextRequest, { params }: RouteParams) => { + // Extract path segments + const { path } = params; + + // Skip NextAuth routes - they should be handled by NextAuth directly + if (path && path.length > 0 && path[0] === 'auth') { + return NextResponse.json({ error: 'Not Found' }, { status: 404 }); + } + + // Check for explicit auth type header + const authTypeHeader = request.headers.get('x-auth-type'); + + // Get optimized auth options + const options = getAuthOptions(path, authTypeHeader || undefined); + + // Create and execute the appropriate proxy handler + return createProxyHandler(options)(request, { params }); +}; + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const DELETE = handler; +export const PATCH = handler; diff --git a/src/vertex-template/app/api/auth/[...nextauth]/options.ts b/src/vertex-template/app/api/auth/[...nextauth]/options.ts new file mode 100644 index 0000000000..9ee1c7bc11 --- /dev/null +++ b/src/vertex-template/app/api/auth/[...nextauth]/options.ts @@ -0,0 +1,355 @@ +import { NextAuthOptions } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { users } from '@/core/apis/users'; +import { jwtDecode } from 'jwt-decode'; +import type { + LoginCredentials, + LoginResponse, + DecodedToken, +} from '@/app/types/users'; +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; +import logger from '@/lib/logger'; +import { getApiBaseUrl, isHCaptchaEnabled } from '@/lib/envConstants'; +import { normalizeOAuthAccessToken } from '@/core/auth/oauth-session'; + +const isProduction = process.env.NODE_ENV === 'production'; + +const isTokenExpired = (exp?: number): boolean => { + if (!exp) return false; + return Date.now() / 1000 > exp; +}; + +const getValidUrl = (value?: string) => { + const url = value?.trim(); + + if (!url) { + return null; + } + + try { + return new URL(url).toString().replace(/\/$/, ''); + } catch { + return null; + } +}; + +const getAzureContainerAppsUrl = () => { + const appName = process.env.CONTAINER_APP_NAME; + const dnsSuffix = process.env.CONTAINER_APP_ENV_DNS_SUFFIX; + + if (!appName || !dnsSuffix || !appName.endsWith('-vertex-preview')) { + return null; + } + + return `https://${appName}.${dnsSuffix}`; +}; + +const azureContainerAppsUrl = getAzureContainerAppsUrl(); +const nextAuthUrl = getValidUrl(process.env.NEXTAUTH_URL); +const nextAuthUrlInternal = getValidUrl(process.env.NEXTAUTH_URL_INTERNAL); + +if (nextAuthUrl) { + process.env.NEXTAUTH_URL = nextAuthUrl; +} else { + delete process.env.NEXTAUTH_URL; +} + +if (nextAuthUrlInternal) { + process.env.NEXTAUTH_URL_INTERNAL = nextAuthUrlInternal; +} else { + delete process.env.NEXTAUTH_URL_INTERNAL; +} + +if (!process.env.NEXTAUTH_URL && azureContainerAppsUrl) { + process.env.NEXTAUTH_URL = azureContainerAppsUrl; + process.env.NEXTAUTH_URL_INTERNAL = + process.env.NEXTAUTH_URL_INTERNAL || azureContainerAppsUrl; + process.env.AUTH_TRUST_HOST = process.env.AUTH_TRUST_HOST || 'true'; +} + +const authSecret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET; +const configuredCookieDomain = + process.env.NEXTAUTH_COOKIE_DOMAIN?.trim() || undefined; +const getCookieDomain = () => { + if (!configuredCookieDomain) { + return undefined; + } + + const referenceUrl = process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL_INTERNAL; + if (!referenceUrl) { + return configuredCookieDomain; + } + + try { + const host = new URL(referenceUrl).hostname.toLowerCase(); + const normalizedDomain = configuredCookieDomain.replace(/^\./, '').toLowerCase(); + const hostMatches = + host === normalizedDomain || host.endsWith(`.${normalizedDomain}`); + + if (hostMatches) { + return configuredCookieDomain; + } + + logger.warn( + '[NextAuth] NEXTAUTH_COOKIE_DOMAIN does not match NEXTAUTH_URL host; disabling cookie domain override.', + { configuredCookieDomain, host } + ); + return undefined; + } catch { + logger.warn( + '[NextAuth] Invalid NEXTAUTH_URL while validating cookie domain; disabling cookie domain override.', + { configuredCookieDomain, referenceUrl } + ); + return undefined; + } +}; +const cookieDomain = getCookieDomain(); +const cookieOptions = { + httpOnly: true, + sameSite: 'lax' as const, + path: '/', + secure: isProduction, + domain: cookieDomain, +}; + +if (isProduction && !authSecret) { + logger.error('[NextAuth] CRITICAL: NEXTAUTH_SECRET is missing in production environment!'); +} + +if (isProduction && !process.env.NEXTAUTH_URL && !process.env.AUTH_TRUST_HOST) { + process.env.AUTH_TRUST_HOST = 'true'; + logger.warn('[NextAuth] WARNING: NEXTAUTH_URL is missing. Dynamic host detection will be used.'); +} + +interface OAuthProfilePayload { + _id: string; + email: string; + firstName: string; + lastName: string; + userName?: string; + organization?: string; + privilege?: string; + profilePicture?: string; +} + +interface OAuthProfileResponse { + success: boolean; + message?: string; + data?: OAuthProfilePayload; +} + +const fetchOAuthProfile = async ( + accessToken: string +): Promise => { + try { + const profileUrl = `${getApiBaseUrl()}/users/profile/enhanced`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(profileUrl, { + method: 'GET', + cache: 'no-store', + signal: controller.signal, + headers: { + Accept: 'application/json', + Authorization: `JWT ${accessToken}`, + }, + }).finally(() => clearTimeout(timeoutId)); + + if (!response.ok) { + return null; + } + + const payload = (await response.json()) as OAuthProfileResponse; + if (!payload?.success || !payload.data?._id) { + return null; + } + + return payload.data; + } catch (error) { + logger.error('Error fetching OAuth profile', { error }); + return null; + } +}; + +export const options: NextAuthOptions = { + secret: authSecret, + useSecureCookies: isProduction, + providers: [ + CredentialsProvider({ + id: 'credentials', + name: 'credentials', + credentials: { + userName: { label: 'Username', type: 'text' }, + password: { label: 'Password', type: 'password' }, + oauthToken: { label: 'OAuth Token', type: 'text' }, + oauthProvider: { label: 'OAuth Provider', type: 'text' }, + captchaToken: { label: 'Captcha Token', type: 'text' }, + }, + async authorize(credentials) { + const oauthToken = normalizeOAuthAccessToken( + typeof credentials?.oauthToken === 'string' ? credentials.oauthToken : '' + ); + + if (oauthToken) { + const profile = await fetchOAuthProfile(oauthToken); + + if (!profile) { + logger.warn('OAuth authorization failed: profile fetch returned null.'); + return null; + } + + // Use JWT decode to get extra fields if the profile doesn't have them + let decoded: DecodedToken | null = null; + try { + decoded = jwtDecode(oauthToken); + } catch { + decoded = null; + } + + return { + id: profile._id, + email: profile.email, + name: `${profile.firstName} ${profile.lastName}`.trim() || profile.email, + userName: profile.userName || decoded?.userName || profile.email, + accessToken: oauthToken, + organization: profile.organization || decoded?.organization || '', + privilege: profile.privilege || decoded?.privilege || '', + firstName: profile.firstName, + lastName: profile.lastName, + country: decoded?.country || '', + timezone: decoded?.timezone || '', + phoneNumber: decoded?.phoneNumber || '', + exp: decoded?.exp, + }; + } + + if (!credentials?.userName || !credentials?.password) { + logger.warn('Authorize call with missing credentials.'); + throw new Error('Username and password are required.'); + } + + const hcaptchaEnabled = isHCaptchaEnabled(); + const captchaToken = + typeof credentials.captchaToken === 'string' + ? credentials.captchaToken.trim() + : ''; + + if (hcaptchaEnabled && !captchaToken) { + logger.warn('Authorize call with missing CAPTCHA token.'); + throw new Error('CAPTCHA validation is required.'); + } + + try { + const loginResponse = (await users.loginWithDetails({ + userName: credentials.userName, + password: credentials.password, + captchaToken: hcaptchaEnabled ? captchaToken : undefined, + } as LoginCredentials)) as LoginResponse; + + if (loginResponse?.token) { + const decoded = jwtDecode(loginResponse.token); + + return { + id: decoded._id, + email: decoded.email, + name: `${decoded.firstName} ${decoded.lastName}`, + userName: decoded.userName, + accessToken: loginResponse.token, + organization: decoded.organization, + privilege: decoded.privilege, + firstName: decoded.firstName, + lastName: decoded.lastName, + country: decoded.country || '', + timezone: decoded.timezone || '', + phoneNumber: decoded.phoneNumber || '', + exp: decoded.exp, + }; + } + + logger.warn('Login API returned success status but no token.', { + userName: credentials.userName, + }); + throw new Error( + 'Authentication server returned an invalid response.' + ); + } catch (error) { + const errorMessage = getApiErrorMessage(error); + logger.error('Authentication error during login', { + userName: credentials.userName, + error: errorMessage, + }); + throw new Error(errorMessage); + } + }, + }), + ], + + cookies: { + sessionToken: { + name: isProduction + ? '__Secure-next-auth.session-token' + : 'next-auth.session-token', + options: cookieOptions, + }, + }, + + session: { + strategy: 'jwt', + maxAge: 24 * 60 * 60, // 24 hours + }, + + jwt: { + maxAge: 24 * 60 * 60, // 24 hours + }, + + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.accessToken = user.accessToken; + token.userName = user.userName; + token.organization = user.organization; + token.privilege = user.privilege; + token.firstName = user.firstName; + token.lastName = user.lastName; + token.country = user.country; + token.timezone = user.timezone; + token.phoneNumber = user.phoneNumber; + token.exp = user.exp; + } + return token; + }, + + async session({ session, token }) { + if (isTokenExpired(token.exp as number | undefined)) { + return { ...session, user: null }; + } + + if (token) { + session.user = { + ...session.user, + id: token.id as string, + accessToken: token.accessToken as string, + userName: token.userName as string, + organization: token.organization as string, + privilege: token.privilege as string, + firstName: token.firstName as string, + lastName: token.lastName as string, + country: token.country as string, + timezone: token.timezone as string, + phoneNumber: token.phoneNumber as string, + exp: token.exp, + }; + } + return session; + }, + }, + + pages: { + signIn: '/login', + error: '/auth-error', + }, + + debug: false, +}; diff --git a/src/vertex-template/app/api/auth/[...nextauth]/route.ts b/src/vertex-template/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000000..566e1e9c28 --- /dev/null +++ b/src/vertex-template/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,37 @@ +import NextAuth from 'next-auth'; +import { options } from './options'; +import type { NextRequest } from 'next/server'; + +const getRequestOrigin = (req: NextRequest) => { + const forwardedProto = req.headers.get('x-forwarded-proto') || 'https'; + const forwardedHost = req.headers.get('x-forwarded-host'); + const host = forwardedHost || req.headers.get('host'); + + if (!host) { + return null; + } + + return `${forwardedProto}://${host}`; +}; + +const handler = NextAuth(options); + +const setRuntimeAuthUrls = (req: NextRequest) => { + const requestOrigin = getRequestOrigin(req); + + if (requestOrigin) { + process.env.NEXTAUTH_URL = requestOrigin; + process.env.NEXTAUTH_URL_INTERNAL = requestOrigin; + process.env.AUTH_TRUST_HOST = process.env.AUTH_TRUST_HOST || 'true'; + } +}; + +export async function GET(req: NextRequest, context: { params: Record }) { + setRuntimeAuthUrls(req); + return handler(req, context); +} + +export async function POST(req: NextRequest, context: { params: Record }) { + setRuntimeAuthUrls(req); + return handler(req, context); +} diff --git a/src/vertex-template/app/api/cloudinary/upload/route.ts b/src/vertex-template/app/api/cloudinary/upload/route.ts new file mode 100644 index 0000000000..c441450e42 --- /dev/null +++ b/src/vertex-template/app/api/cloudinary/upload/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { getCloudinaryName, getCloudinaryApiKey, getCloudinaryApiSecret } from '@/lib/envConstants'; + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const file = formData.get('file') as File | null; + const requestedFolder = formData.get('folder') as string || 'feedback'; + const tags = formData.get('tags') as string || ''; + + const ALLOWED_FOLDERS = new Set(['feedback']); + const folder = ALLOWED_FOLDERS.has(requestedFolder) ? requestedFolder : 'feedback'; + + const VALID_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); + const MAX_BYTES = 2 * 1024 * 1024; // 2MB + + if (!file) { + return NextResponse.json({ success: false, error: 'No file provided' }, { status: 400 }); + } + + if (!VALID_TYPES.has(file.type)) { + return NextResponse.json({ success: false, error: 'Invalid file type' }, { status: 400 }); + } + + if (file.size > MAX_BYTES) { + return NextResponse.json({ success: false, error: 'File too large (max 2MB)' }, { status: 400 }); + } + + let cloudName: string; + let apiKey: string; + let apiSecret: string; + + try { + cloudName = getCloudinaryName(); + apiKey = getCloudinaryApiKey(); + apiSecret = getCloudinaryApiSecret(); + } catch (configError: unknown) { + const errMsg = configError instanceof Error ? configError.message : 'Cloudinary configuration is incomplete on the server.'; + return NextResponse.json({ + success: false, + error: errMsg + }, { status: 500 }); + } + + const timestamp = Math.round(new Date().getTime() / 1000).toString(); + + // Prepare parameters to sign (sorted alphabetically) + const paramsToSign: Record = { + folder, + timestamp, + }; + if (tags) { + paramsToSign.tags = tags; + } + + const sortedKeys = Object.keys(paramsToSign).sort(); + const paramString = sortedKeys + .map(key => `${key}=${paramsToSign[key]}`) + .join('&'); + + const stringToSign = paramString + apiSecret; + const signature = crypto + .createHash('sha1') + .update(stringToSign) + .digest('hex'); + + // Build the request body for Cloudinary + const cloudinaryFormData = new FormData(); + cloudinaryFormData.append('file', file); + cloudinaryFormData.append('api_key', apiKey); + cloudinaryFormData.append('timestamp', timestamp); + cloudinaryFormData.append('signature', signature); + cloudinaryFormData.append('folder', folder); + if (tags) { + cloudinaryFormData.append('tags', tags); + } + + const cloudinaryUrl = `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`; + const response = await fetch(cloudinaryUrl, { + method: 'POST', + body: cloudinaryFormData, + signal: AbortSignal.timeout(15000), + }); + + const result = await response.json(); + + if (!response.ok) { + return NextResponse.json({ + success: false, + error: result.error?.message || 'Failed to upload to Cloudinary' + }, { status: response.status }); + } + + return NextResponse.json({ + success: true, + secure_url: result.secure_url, + public_id: result.public_id, + }); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : 'Server error during upload'; + return NextResponse.json({ + success: false, + error: errMsg + }, { status: 500 }); + } +} diff --git a/src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts b/src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts new file mode 100644 index 0000000000..007bf60540 --- /dev/null +++ b/src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { options } from "@/app/api/auth/[...nextauth]/options"; +import logger from "@/lib/logger"; +import { networkService } from "@/core/services/network-service"; + +async function getAuthToken(): Promise { + const session = await getServerSession(options); + return (session as { user?: { accessToken?: string } })?.user?.accessToken || null; +} + +export async function PUT( + req: NextRequest, + { params }: { params: { id: string; action: string } } +) { + try { + const { id, action } = params; + const allowedActions = ["approve", "deny", "review"]; + + if (!allowedActions.includes(action)) { + return NextResponse.json({ message: "Invalid action" }, { status: 400 }); + } + + const token = await getAuthToken(); + if (!token) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const adminSecret = process.env.ADMIN_SECRET; + if (!adminSecret) { + return NextResponse.json({ message: "Server configuration error" }, { status: 500 }); + } + + const body = await req.json(); + const notes = body.reviewer_notes || ""; + + const responseData = await networkService.updateNetworkRequestStatus( + id, + action, + notes, + token, + adminSecret + ); + + return NextResponse.json(responseData, { status: 200 }); + } catch (error: unknown) { + const err = error as { message: string; status?: number; data?: unknown }; + logger.error(`Error updating network request status in route handler: ${err.message}`); + + return NextResponse.json( + err.data || { message: err.message || "Internal server error" }, + { status: err.status || 500 } + ); + } +} diff --git a/src/vertex-template/app/api/devices/network-creation-requests/route.ts b/src/vertex-template/app/api/devices/network-creation-requests/route.ts new file mode 100644 index 0000000000..0d0c742933 --- /dev/null +++ b/src/vertex-template/app/api/devices/network-creation-requests/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { options } from "../../auth/[...nextauth]/options"; +import logger from "@/lib/logger"; +import { networkService } from "@/core/services/network-service"; +import type { NetworkRequestValues } from "@/components/features/networks/schema"; + +export async function GET() { + try { + const session = await getServerSession(options); + const token = (session as { user?: { accessToken?: string } })?.user?.accessToken; + + if (!token) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const adminSecret = process.env.ADMIN_SECRET; + if (!adminSecret) { + return NextResponse.json({ message: "Server configuration error" }, { status: 500 }); + } + + const data = await networkService.getNetworkCreationRequests(token, adminSecret); + + return NextResponse.json({ network_creation_requests: data, success: true }, { status: 200 }); + } catch (error: unknown) { + if (error instanceof Error && error.message === "NOT_FOUND") { + return NextResponse.json({ message: "Resource not found" }, { status: 404 }); + } + const err = error as { message: string; status?: number; data?: unknown }; + logger.error(`Error fetching network requests in route handler: ${err.message}`); + return NextResponse.json( + err.data || { message: err.message || "Internal server error" }, + { status: err.status || 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + let body: NetworkRequestValues; + try { + body = (await req.json()) as NetworkRequestValues; + } catch { + return NextResponse.json({ message: "Invalid JSON payload" }, { status: 400 }); + } + + const data = await networkService.submitNetworkRequest(body); + return NextResponse.json(data, { status: 200 }); + } catch (error: unknown) { + const err = error as { message: string; status?: number; data?: unknown }; + logger.error(`Error submitting network request in route handler: ${err.message}`); + return NextResponse.json( + err.data || { message: err.message || "Internal server error" }, + { status: err.status || 500 } + ); + } +} diff --git a/src/vertex-template/app/api/log-to-slack/route.ts b/src/vertex-template/app/api/log-to-slack/route.ts new file mode 100644 index 0000000000..854585382d --- /dev/null +++ b/src/vertex-template/app/api/log-to-slack/route.ts @@ -0,0 +1,257 @@ +import { NextRequest, NextResponse } from 'next/server'; +import axios, { AxiosError } from 'axios'; + +interface ErrorResponse { + status?: number; + statusText?: string; + data?: unknown; +} + +interface ErrorContext { + status?: number; + method?: string; + url?: string; + error?: { + status?: number; + response?: { + status?: number; + }; + }; + [key: string]: unknown; +} + +interface LogBody { + level: 'info' | 'warn' | 'error'; + message: string; + context: ErrorContext; +} + +interface SlackTextBlock { + type: 'plain_text' | 'mrkdwn'; + text: string; + emoji?: boolean; +} + +interface SlackSectionBlock { + type: 'section'; + text?: SlackTextBlock; + fields?: SlackTextBlock[]; +} + +interface SlackHeaderBlock { + type: 'header'; + text: SlackTextBlock; +} + +interface SlackAttachment { + color: string; + blocks: SlackSectionBlock[]; +} + +interface SlackPayload { + blocks: (SlackHeaderBlock | SlackSectionBlock)[]; + attachments: SlackAttachment[]; +} + +// Get environment info +function getEnvironmentInfo() { + const env = + process.env.NEXT_PUBLIC_ALLOW_DEV_TOOLS ?? + process.env.NODE_ENV ?? + 'development'; + + const environment = + env === 'staging' || env === 'production' ? env : 'development'; + + return { environment }; +} + +// Check if an error should be ignored for Slack notifications +function shouldIgnoreError(context: ErrorContext): boolean { + const ignoredStatusCodes = [400, 404]; + + const status = context.status; + const errorStatus = context.error?.status; + const responseStatus = context.error?.response?.status; + + return ( + ignoredStatusCodes.includes(status ?? -1) || + ignoredStatusCodes.includes(errorStatus ?? -1) || + ignoredStatusCodes.includes(responseStatus ?? -1) + ); +} + +// Simple in-memory cache for deduplication +const errorCache = new Set(); +const ERROR_CACHE_TTL = 1000 * 60 * 15; // 15 minutes + +export async function POST(request: NextRequest) { + try { + const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL; + + if (!SLACK_WEBHOOK_URL) { + console.warn( + 'Slack webhook URL not configured - check your .env file' + ); + console.warn('Expected environment variable: SLACK_WEBHOOK_URL'); + return NextResponse.json( + { + success: true, + skipped: true, + reason: 'Slack webhook URL not configured', + }, + { status: 200 } + ); + } + + const body = (await request.json()) as LogBody; + const { level, message, context } = body; + + if (shouldIgnoreError(context)) { + return NextResponse.json({ + success: true, + skipped: true, + reason: 'Ignored status code', + }); + } + + const fingerprint = `${level}:${message}:${JSON.stringify(context)}`; + if (errorCache.has(fingerprint)) { + return NextResponse.json({ + success: true, + skipped: true, + reason: 'Deduplication', + }); + } + + errorCache.add(fingerprint); + setTimeout(() => { + errorCache.delete(fingerprint); + }, ERROR_CACHE_TTL); + + const { environment } = getEnvironmentInfo(); + + const emoji = + level === 'error' ? '🔴' : level === 'warn' ? '⚠️' : 'ℹ️'; + const color = + level === 'error' + ? '#FF0000' + : level === 'warn' + ? '#FFA500' + : '#36C5F0'; + + const slackPayload: SlackPayload = { + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `${emoji} ${level.toUpperCase()} [${environment}]: ${message}`, + emoji: true, + }, + }, + ], + attachments: [ + { + color, + blocks: [ + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Environment:*\n${environment}`, + }, + { + type: 'mrkdwn', + text: `*Time:*\n${new Date().toISOString()}`, + }, + ], + }, + ], + }, + ], + }; + + // Add URL if available in context + if (context.url) { + slackPayload.attachments[0].blocks.push({ + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*URL:*\n${context.url}`, + }, + ], + }); + } + + // Add error details if available + if (context.status || context.method) { + slackPayload.attachments[0].blocks.push({ + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Request Details:*\n${context.method ?? 'Unknown'}${ + context.status ? ` (${context.status})` : '' + }`, + }, + ], + }); + } + + // Add context details if available + const { ...filteredContext } = context; + if (Object.keys(filteredContext).length > 0) { + slackPayload.attachments[0].blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Context:*\n\`\`\`${JSON.stringify( + filteredContext, + null, + 2 + )}\`\`\``, + }, + }); + } + + const response = await axios.post(SLACK_WEBHOOK_URL, slackPayload, { + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.data !== 'ok') { + console.warn('⚠️ [SLACK API] Unexpected response from Slack:', response.data); + } + + return NextResponse.json({ success: true }); + } catch (error: unknown) { + const err = error as AxiosError; + + console.error('Error sending to Slack:', { + message: err.message, + status: err.response?.status, + data: err.response?.data, + config: { + url: err.config?.url?.substring(0, 50) + '...', + method: err.config?.method, + }, + }); + + return NextResponse.json( + { + success: false, + error: err.message, + details: { + status: err.response?.status, + statusText: err.response?.statusText, + }, + }, + { status: 500 } + ); + } +} diff --git a/src/vertex-template/app/api/network/route.ts b/src/vertex-template/app/api/network/route.ts new file mode 100644 index 0000000000..63d8296dac --- /dev/null +++ b/src/vertex-template/app/api/network/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { options } from "../auth/[...nextauth]/options"; +import logger from "@/lib/logger"; +import { CreateNetworkPayload, CreateNetworkResponse } from "@/core/apis/networks"; +import axios from "axios"; +import { networkFormSchema } from "@/components/features/networks/schema"; + +/** + * Retrieves the access token from the server-side session. + * @returns The access token or null if not found. + */ +async function getAuthToken(): Promise { + const session = await getServerSession(options); + + if (!session) { + logger.warn("getServerSession returned null"); + return null; + } + + const accessToken = (session as unknown as { user?: { accessToken?: string } })?.user?.accessToken; + + if (!accessToken) { + logger.warn(`Session found but no accessToken in session.user. Session keys: ${Object.keys(session).join(', ')}`); + } + + return accessToken || null; +} + +export async function POST(req: NextRequest) { + try { + logger.info("Sensor Manufacturer creation request received"); + + const body = await req.json(); + const validationResult = networkFormSchema.safeParse(body); + + if (!validationResult.success) { + logger.warn(`Invalid payload: ${JSON.stringify(validationResult.error.issues)}`); + return NextResponse.json( + { message: "Invalid payload", errors: validationResult.error.issues }, + { status: 400 } + ); + } + + const networkData = validationResult.data; + + const token = await getAuthToken(); + if (!token) { + logger.error("No JWT token found in session"); + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const authHeader = token.startsWith("JWT ") ? token : `JWT ${token}`; + + const adminSecret = process.env.ADMIN_SECRET; + if (!adminSecret) { + const envKeys = Object.keys(process.env).filter(key => key.includes('SECRET') || key.includes('ADMIN')); + logger.error(`ADMIN_SECRET is not configured on the server. NODE_ENV: ${process.env.NODE_ENV}, Related env keys: ${envKeys.join(', ')}`); + return NextResponse.json({ message: "Server configuration error." }, { status: 500 }); + } + + logger.debug("ADMIN_SECRET found, constructing payload"); + + const payload: CreateNetworkPayload = { ...networkData, admin_secret: adminSecret }; + + const backendApiUrl = `${process.env.NEXT_PUBLIC_API_URL}/users/networks`; + + const apiResponse = await axios.post(backendApiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + "X-Auth-Type": "JWT", + }, + }); + + logger.info(`Sensor Manufacturer created successfully - ID: ${apiResponse.data.created_network?._id}`); + + return NextResponse.json(apiResponse.data, { status: 200 }); + } catch (error: unknown) { + const err = error as Error; + logger.error(`API route error: ${err.message}`); + + if (axios.isAxiosError(error) && error.response) { + const errorMessage = error.response.data?.message || error.response.data?.errors?.[0]?.message || 'Unknown error'; + logger.error(`Upstream API error - Status: ${error.response.status} ${error.response.statusText}, Message: ${errorMessage}`); + + return NextResponse.json( + error.response.data || { message: "An error occurred" }, + { status: error.response.status } + ); + } + + return NextResponse.json({ message: "An internal server error occurred." }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/vertex-template/app/auth-error/page.tsx b/src/vertex-template/app/auth-error/page.tsx new file mode 100644 index 0000000000..551b586c57 --- /dev/null +++ b/src/vertex-template/app/auth-error/page.tsx @@ -0,0 +1,154 @@ +"use client" + +import { Suspense } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { AlertCircle, ArrowLeft, ShieldAlert, Settings, Mail, UserPlus, Link as LinkIcon } from 'lucide-react' +import ReusableButton from "@/components/shared/button/ReusableButton" +import { vertexConfig } from "@/vertex.config"; + +type ErrorType = { + title: string; + message: string; + icon: React.ReactNode; + description: string; +} + +const ERROR_MAP: Record = { + Configuration: { + title: 'Configuration Error', + message: 'There is a problem with the server configuration.', + description: 'This is usually caused by a missing environment variable or a mismatch in the NEXTAUTH_URL. Please check the server logs.', + icon: , + }, + AccessDenied: { + title: 'Access Denied', + message: 'You do not have permission to sign in.', + description: 'Your account might not have the necessary privileges, or your sign-in was restricted by a security policy.', + icon: , + }, + Verification: { + title: 'Link Expired', + message: 'The sign-in link is no longer valid.', + description: 'The verification token has either expired or has already been used. Please try signing in again to receive a new link.', + icon: , + }, + OAuthSignin: { + title: 'Sign-in Failed', + message: 'Could not start the external authentication process.', + description: 'An error occurred while trying to connect to the authentication provider. This could be a temporary network issue.', + icon: , + }, + OAuthCallback: { + title: 'Authentication Failed', + message: 'Error during the external authentication callback.', + description: 'The response from the authentication provider was invalid or could not be processed.', + icon: , + }, + OAuthCreateAccount: { + title: 'Account Creation Failed', + message: 'Could not create a new user account via OAuth.', + description: 'There was an issue creating your account profile. This might be due to missing required information from the provider.', + icon: , + }, + EmailCreateAccount: { + title: 'Account Creation Failed', + message: 'Could not create a new user account via email.', + description: 'An error occurred while setting up your account. Please try again or contact support.', + icon: , + }, + Callback: { + title: 'Callback Error', + message: 'An error occurred during the authentication callback.', + description: 'The authentication process failed at the final validation step.', + icon: , + }, + OAuthAccountNotLinked: { + title: 'Account Linked Elsewhere', + message: 'This email is already associated with another provider.', + description: 'To confirm your identity, please sign in using the provider you originally used for this email address.', + icon: , + }, + Default: { + title: 'Authentication Error', + message: 'An unexpected error occurred.', + description: 'Something went wrong during the sign-in process. Please try again or contact our support team if the issue persists.', + icon: , + }, +} + +function AuthErrorContent() { + const router = useRouter(); + const searchParams = useSearchParams() + const errorKey = searchParams.get('error') || 'Default' + const error = ERROR_MAP[errorKey] || ERROR_MAP.Default + + return ( +
+
+ {`${vertexConfig.org.name} +
+ +
+
+
+ {error.icon} +
+

{error.title}

+

{error.message}

+
+

+ {error.description} +

+ {errorKey !== 'Default' && ( +

+ Error Code: {errorKey} +

+ )} +
+
+ +
+ router.push("/login")} + > + Back to Login + + + Contact Support + +
+
+ +
+ © {new Date().getFullYear()} {vertexConfig.org.name}. All rights reserved. +
+
+ ) +} + +export default function AuthErrorPage() { + return ( + +
+
+ }> + +
+ ) +} diff --git a/src/vertex-template/app/changelog.md b/src/vertex-template/app/changelog.md new file mode 100644 index 0000000000..f6af1b535a --- /dev/null +++ b/src/vertex-template/app/changelog.md @@ -0,0 +1,9 @@ +# Vertex Template - Changelog + +## Version 1.0.0 +**Released:** June 2026 + +### Initial Release +- Created standalone boilerplate template for Vertex applications. +- Removed AirQo-specific branding, components, and hardcoded API dependencies. +- Added mock adapter for initial scaffolding. diff --git a/src/vertex-template/app/client-layout.tsx b/src/vertex-template/app/client-layout.tsx new file mode 100644 index 0000000000..0b3ab7eb5b --- /dev/null +++ b/src/vertex-template/app/client-layout.tsx @@ -0,0 +1,31 @@ +'use client'; + +import type React from 'react'; +import dynamic from 'next/dynamic'; + +const Toaster = dynamic( + () => import('@/components/shared/toast/ReusableToast').then(mod => mod.Toaster), + { ssr: false } +); + +import Providers from "./providers" + +import { Session } from "next-auth"; + +export default function ClientLayout({ + children, + session +}: { + children: React.ReactNode; + session: Session | null; +}) { + return ( + + + {children} + + + ) +} diff --git a/src/vertex-template/app/error.tsx b/src/vertex-template/app/error.tsx new file mode 100644 index 0000000000..fb9ca55f27 --- /dev/null +++ b/src/vertex-template/app/error.tsx @@ -0,0 +1,36 @@ +"use client" + +import { useEffect } from "react" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" +import logger from "@/lib/logger" + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const router = useRouter() + useEffect(() => { + logger.error('Application error', error); + }, [error]) + + return ( +
+
+

Something went wrong!

+

+ We apologize for the inconvenience. The application encountered an unexpected error. +

+
+ + +
+
+
+ ) +} diff --git a/src/vertex-template/app/favicon-16x16.png b/src/vertex-template/app/favicon-16x16.png new file mode 100644 index 0000000000..7515f3dddc Binary files /dev/null and b/src/vertex-template/app/favicon-16x16.png differ diff --git a/src/vertex-template/app/favicon-32x32.png b/src/vertex-template/app/favicon-32x32.png new file mode 100644 index 0000000000..a3fa9b234b Binary files /dev/null and b/src/vertex-template/app/favicon-32x32.png differ diff --git a/src/vertex-template/app/favicon.ico b/src/vertex-template/app/favicon.ico new file mode 100644 index 0000000000..1a323901ae Binary files /dev/null and b/src/vertex-template/app/favicon.ico differ diff --git a/src/vertex-template/app/fonts/GeistMonoVF.woff b/src/vertex-template/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000000..f2ae185cbf Binary files /dev/null and b/src/vertex-template/app/fonts/GeistMonoVF.woff differ diff --git a/src/vertex-template/app/fonts/GeistVF.woff b/src/vertex-template/app/fonts/GeistVF.woff new file mode 100644 index 0000000000..1b62daacff Binary files /dev/null and b/src/vertex-template/app/fonts/GeistVF.woff differ diff --git a/src/vertex-template/app/globals.css b/src/vertex-template/app/globals.css new file mode 100644 index 0000000000..96cdca0c58 --- /dev/null +++ b/src/vertex-template/app/globals.css @@ -0,0 +1,543 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --vertex-ui-top-offset: 0px; + /* Colors */ + --background: 246 246 247; + --foreground: 28 29 32; + --primary: 20 95 255; + --primary-foreground: 255 255 255; + --secondary: 241 245 249; + --secondary-foreground: 15 23 42; + --muted: 241 245 249; + --muted-foreground: 91 105 124; + --accent: 241 245 249; + --accent-foreground: 15 23 42; + --destructive: 239 68 68; + --destructive-foreground: 255 255 255; + --border: 226 232 240; + --input: 226 232 240; + --ring: 20 95 255; + --card: 255 255 255; + --card-foreground: 15 23 42; + --popover: 255 255 255; + --popover-foreground: 15 23 42; + --text: 31 41 55; + --heading: 16 24 40; + + /* Primary color variations for highlights */ + --primary-50: 235 245 255; + /* Very light blue */ + --primary-100: 219 234 254; + /* Light blue */ + --primary-700: 3 105 161; + /* Dark blue */ + --primary-800: 7 89 133; + /* Darker blue */ + --primary-900: 12 74 110; + /* Darkest blue */ + + /* Layout */ + --radius: 0.5rem; + + /* Scrollbars */ + --scrollbar-track: 241 245 249; + --scrollbar-thumb: 100 116 139; + --scrollbar-hover: 51 65 85; + + /* Typography */ + --font-mono: + 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Source Code Pro', + monospace; + --heading-weight: 500; + --heading-line-height: 1.25; + --heading-letter-spacing: -0.025em; + } + + .dark { + --background: 29 31 32; + --foreground: 237 237 237; + --primary: 20 95 255; + --primary-foreground: 255 255 255; + --secondary: 30 41 59; + --secondary-foreground: 248 250 252; + --muted: 30 41 59; + --muted-foreground: 148 163 184; + --accent: 30 41 59; + --accent-foreground: 248 250 252; + --destructive: 220 38 38; + --destructive-foreground: 255 255 255; + --border: 51 65 85; + --input: 51 65 85; + --ring: 20 95 255; + --card: 29 31 32; + --card-foreground: 248 250 252; + --popover: 29 31 32; + --popover-foreground: 248 250 252; + --text: 249 250 251; + --heading: 255 255 255; + --scrollbar-track: 30 41 59; + --scrollbar-thumb: 148 163 184; + --scrollbar-hover: 51 65 85; + } +} + +/* ========================================================================== + BASE STYLES + ========================================================================== */ +html { + overflow: hidden; +} + +body, +html { + color: rgb(var(--foreground)); + background: rgb(var(--background)); + font-family: var(--font-inter), Arial, Helvetica, sans-serif; +} + +.dark body, +.dark html { + background: rgb(var(--background)); +} + +/* ========================================================================== + TYPOGRAPHY SYSTEM + ========================================================================== */ + +@layer base { + + /* Base heading styles */ + h1, + h2, + h3, + h4, + h5, + h6 { + color: rgb(var(--heading)); + font-weight: var(--heading-weight); + line-height: var(--heading-line-height); + letter-spacing: var(--heading-letter-spacing); + margin: 0; + } + + /* Individual heading sizes */ + h1 { + font-size: 2.25rem; + line-height: 1.1; + letter-spacing: -0.04em; + } + + h2 { + font-size: 1.875rem; + line-height: 1.2; + letter-spacing: -0.03em; + } + + h3 { + font-size: 1.5rem; + line-height: 1.25; + letter-spacing: -0.02em; + } + + h4 { + font-size: 1.25rem; + line-height: 1.3; + } + + h5 { + font-size: 1.125rem; + line-height: 1.4; + } + + h6 { + font-size: 1rem; + line-height: 1.5; + } + + /* Links */ + a { + color: rgb(var(--primary)); + text-decoration: none; + transition: color 0.2s ease; + } + + button:focus:not(:focus-visible), + input:focus:not(:focus-visible), + textarea:focus:not(:focus-visible), + select:focus:not(:focus-visible) { + outline: 2px solid rgb(var(--ring)); + outline-offset: 2px; + } + + /* Lists */ + ul, + ol { + color: rgb(var(--foreground)); + line-height: 1.6; + } + + ul { + list-style-type: disc; + padding-left: 1.5rem; + } + + ol { + list-style-type: decimal; + padding-left: 1.5rem; + } + + /* Dropdown menus - remove list styles */ + [role='listbox'] { + list-style: none; + padding-left: 0; + } + + /* Toasts and alerts - remove list styles */ + [role='alert'], + [role='status'], + [aria-live='polite'], + [aria-live='assertive'] { + list-style: none !important; + } + + [role='alert'] *, + [role='status'] *, + [aria-live='polite'] *, + [aria-live='assertive'] * { + list-style: none !important; + } + + /* Blockquotes */ + blockquote { + border-left: 4px solid rgb(var(--border)); + padding-left: 1rem; + margin: 1.5rem 0; + color: rgb(var(--muted-foreground)); + font-style: italic; + } + + /* Code blocks */ + code { + font-family: var(--font-mono); + font-size: 0.875em; + background: rgb(var(--muted)); + color: rgb(var(--foreground)); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + border: 1px solid rgb(var(--border)); + } + + pre { + font-family: var(--font-mono); + background: rgb(var(--muted)); + color: rgb(var(--foreground)); + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid rgb(var(--border)); + overflow-x: auto; + line-height: 1.5; + } + + pre code { + background: transparent; + border: none; + padding: 0; + } + + /* Form elements */ + input, + textarea, + select { + font-family: inherit; + font-size: inherit; + line-height: inherit; + } + + /* Focus styles - removed blue ring */ + button:focus, + input:focus, + textarea:focus, + select:focus { + outline: none; + } + + /* Alternative focus styles for accessibility */ + textarea:focus { + border-color: rgb(var(--ring)); + box-shadow: 0 0 0 1px rgb(var(--ring)); + } + + a:focus { + background-color: rgb(var(--accent)); + color: rgb(var(--accent-foreground)); + } + + /* Responsive typography */ + @media (max-width: 640px) { + h1 { + font-size: 1.875rem; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.25rem; + } + } +} + +/* ========================================================================== + UTILITIES + ========================================================================== */ + +@layer utilities { + .text-balance { + text-wrap: balance; + } + + /* Text color utilities */ + .text-primary { + color: rgb(var(--primary)); + } + + .text-primary-foreground { + color: rgb(var(--primary-foreground)); + } + + .text-secondary { + color: rgb(var(--secondary)); + } + + .text-secondary-foreground { + color: rgb(var(--secondary-foreground)); + } + + .text-muted { + color: rgb(var(--muted-foreground)); + } + + .text-accent { + color: rgb(var(--accent)); + } + + .text-accent-foreground { + color: rgb(var(--accent-foreground)); + } + + .text-destructive { + color: rgb(var(--destructive)); + } + + .text-destructive-foreground { + color: rgb(var(--destructive-foreground)); + } + + .text-foreground { + color: rgb(var(--foreground)); + } + + .text-background { + color: rgb(var(--background)); + } + + .text-text { + color: rgb(var(--text)); + } + + .text-heading { + color: rgb(var(--heading)); + } +} + +/* ========================================================================== + COMPONENTS + ========================================================================== */ + +/* Spinner */ +.secondary-main-loader { + width: 50px; + aspect-ratio: 1; + border-radius: 50%; + background: + radial-gradient(farthest-side, rgb(var(--primary)) 94%, #0000) top/8px 8px no-repeat, + conic-gradient(#0000 30%, rgb(var(--primary))); + -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 8px), #000 0); + animation: l13 1s infinite linear; +} + +@keyframes l13 { + 100% { + transform: rotate(1turn); + } +} + +/* ========================================================================== + SCROLLBARS + ========================================================================== */ + +/* Hide scrollbars */ +.custom-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Default scrollbar styles */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgb(var(--scrollbar-track)); +} + +::-webkit-scrollbar-thumb { + background: rgb(var(--scrollbar-thumb)); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgb(var(--scrollbar-hover)); +} + +/* Horizontal scrollbars */ +.country-scroll-bar { + overflow-x: auto; + overflow-y: hidden; + cursor: pointer; +} + +.country-scroll-bar::-webkit-scrollbar { + height: 4px; + display: none; +} + +.country-scroll-bar:hover::-webkit-scrollbar { + display: block; +} + +.map-scrollbar::-webkit-scrollbar { + height: 2px; +} + +/* ========================================================================== + FLOWBITE TOOLTIP STYLES + ========================================================================== */ + +/* Basic tooltip styling for Flowbite tooltips */ +.tooltip { + background-color: rgb(var(--card)) !important; + /* gray-800 */ + color: rgb(var(--card-foreground)) !important; + /* white */ + border-radius: 0.375rem !important; + /* rounded-md */ + padding: 0.5rem 0.75rem !important; + font-size: 0.875rem !important; + /* text-sm */ + font-weight: 500 !important; + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1) !important; + z-index: 50 !important; + max-width: 20rem !important; + word-wrap: break-word !important; +} + +/* Arrow styling */ +.tooltip-arrow:before { + background-color: inherit !important; +} + +/* ========================================================================== + PRINT STYLES FOR LABELS (GLOBAL) + ========================================================================== */ + +@media print { + @page { + size: 4in 6in; + margin: 0; + } + + /* Hide everything */ + body * { + visibility: hidden !important; + } + + /* Remove parent containers completely from layout */ + body > *:not(.print-label) { + display: none !important; + } + + /* Make the print label the only element positioned */ + .print-label, .print-label * { + visibility: visible !important; + } + + .print-label { + position: absolute !important; + top: 0 !important; + left: 0 !important; + + width: 4in !important; + height: 6in !important; + padding: 0.15in !important; + + background: white !important; + border: 2px solid black !important; + box-sizing: border-box !important; + overflow: hidden !important; + } + + /* Prevent Tailwind layout constraints */ + html, body { + width: 100% !important; + height: auto !important; + margin: 0 !important; + padding: 0 !important; + background: white !important; + overflow: visible !important; + } +} + +@property --border-angle { + syntax: ''; + inherits: false; + initial-value: 0deg; +} + +@keyframes spin-border { + to { --border-angle: 360deg; } +} + +@keyframes coach-fade-out { + 0%, 80% { opacity: 1; transform: translateY(0); } + 100% { opacity: 0; transform: translateY(-4px); pointer-events: none; } +} + +.rainbow-spin-border { + position: relative; + border-radius: 9999px; + padding: 2px; + background: conic-gradient( + from var(--border-angle), + #ef4444, #eab308, #22c55e, #6366f1, #ef4444 + ); + animation: spin-border 2s linear infinite; +} + +.rainbow-spin-border-inner { + border-radius: 9999px; + width: 100%; + height: 100%; +} + +.coach-mark { + animation: coach-fade-out 4s ease-in-out forwards; + animation-delay: 1s; +} \ No newline at end of file diff --git a/src/vertex-template/app/layout.tsx b/src/vertex-template/app/layout.tsx new file mode 100644 index 0000000000..f62aa6bab3 --- /dev/null +++ b/src/vertex-template/app/layout.tsx @@ -0,0 +1,92 @@ +import type React from 'react'; +import type { Metadata } from 'next'; +import './globals.css'; +import ClientLayout from './client-layout'; +import { Inter } from 'next/font/google'; +import { getServerSession } from "next-auth/next"; +import { options } from "@/app/api/auth/[...nextauth]/options"; +import logger from '@/lib/logger'; +import { vertexConfig } from '@/vertex.config'; + +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', +}); + +export const metadata: Metadata = { + title: { + template: `%s | ${vertexConfig.org.name}`, + default: vertexConfig.org.name, + }, + description: vertexConfig.org.name + " is a leading air quality monitoring platform.", + keywords: [ + 'air quality', + 'monitoring', + 'analytics', + 'environment', + 'data', + 'management', + 'device', + ], + authors: [{ name: `${vertexConfig.org.shortName} Team` }], + creator: vertexConfig.org.name, + publisher: vertexConfig.org.name, + openGraph: { + title: vertexConfig.org.name, + description: "Leading air quality device management platform", + type: 'website', + url: vertexConfig.org.websiteUrl, + images: [ + { + url: vertexConfig.org.logo, + width: 1200, + height: 630, + alt: vertexConfig.org.name, + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: vertexConfig.org.name, + description: "Leading air quality device management platform", + images: ['/favicon.ico'], + }, + icons: { + icon: '/favicon.ico', + apple: '/favicon.ico', + }, +}; + +const hexToRgbValues = (hex: string) => { + const normalizedHex = hex.replace('#', ''); + const r = parseInt(normalizedHex.slice(0, 2), 16); + const g = parseInt(normalizedHex.slice(2, 4), 16); + const b = parseInt(normalizedHex.slice(4, 6), 16); + return `${r} ${g} ${b}`; +}; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + let session = null; + try { + session = await getServerSession(options); + } catch (error) { + logger.error("Failed to fetch session:", { error }); + } + + const primaryRgb = hexToRgbValues(vertexConfig.org.primaryColor); + + return ( + + + + + + {children} + + ); +} diff --git a/src/vertex-template/app/login/page.tsx b/src/vertex-template/app/login/page.tsx new file mode 100644 index 0000000000..846ef749cd --- /dev/null +++ b/src/vertex-template/app/login/page.tsx @@ -0,0 +1,407 @@ +"use client" +import { CookieInfoBanner } from '@/components/features/auth/cookie-info-banner'; +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod" +import Link from "next/link" +import Image from "next/image" +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useSearchParams } from "next/navigation"; +import { getSession, signIn } from "next-auth/react"; +import { Form, FormField } from "@/components/ui/form" +import { signUpUrl, forgotPasswordUrl } from "@/core/urls" +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField" +import ReusableButton from "@/components/shared/button/ReusableButton" +import { useBanner, BannerSlot } from "@/context/banner-context" +import { HCaptchaWidget, type HCaptchaWidgetHandle } from "@/components/ui/hcaptcha-widget" +import logger from "@/lib/logger" +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useAppDispatch } from "@/core/redux/hooks"; +import { isHCaptchaEnabled } from "@/lib/envConstants"; +import { + setLoggingOut, +} from "@/core/redux/slices/userSlice"; +import { getLastActiveModule } from "@/core/utils/userPreferences"; +import { ROUTE_LINKS } from "@/core/routes"; +import SocialAuthSection from "@/components/features/auth/social-auth-section"; +import { motion, AnimatePresence } from "framer-motion"; +import { vertexConfig } from "@/vertex.config"; + + +const loginSchema = z.object({ + userName: z.string().email({ message: "Please enter a valid email address" }), + password: z.string().min(8, { message: "Password must be at least 8 characters long" }), +}) + +export default function LoginPage() { + const { showBanner, hideBanner } = useBanner(); + const [isLoading, setIsLoading] = useState(false) + const [step, setStep] = useState<'email' | 'password'>('email'); + const [captchaToken, setCaptchaToken] = useState(""); + const captchaRef = useRef(null); + const hcaptchaEnabled = useMemo(() => isHCaptchaEnabled(), []); + const searchParams = useSearchParams(); + const callbackUrl = useMemo(() => { + const raw = searchParams.get("callbackUrl"); + if (!raw) return ""; + try { + const parsed = new URL(raw, window.location.origin); + if (parsed.origin !== window.location.origin) return ""; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return ""; + } + }, [searchParams]); + const waitForSession = useCallback(async () => { + const attempts = 8; + const delayMs = 150; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + const session = await getSession(); + if (session?.user) { + return session; + } + + if (attempt < attempts - 1) { + await new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); + } + } + + return null; + }, []); + + const form = useForm>({ + resolver: zodResolver(loginSchema), + defaultValues: { + userName: "", + password: "", + }, + }) + + const dispatch = useAppDispatch(); + const isMounted = useRef(true); + + const [platform, setPlatform] = useState<'win' | 'linux' | 'other' | null>(null); + const [isElectron, setIsElectron] = useState(false); + + useEffect(() => { + isMounted.current = true; + // Reset logout state when login page mounts + dispatch(setLoggingOut(false)); + + // OS Detection for download link and platform check + const userAgent = window.navigator.userAgent.toLowerCase(); + const isWin = userAgent.includes('win'); + const isLinux = userAgent.includes('linux'); + setIsElectron(userAgent.includes('electron')); + + if (isWin) { + setPlatform('win'); + } else if (isLinux) { + setPlatform('linux'); + } else { + setPlatform('other'); + } + + const authError = searchParams.get('error'); + if (authError === 'oauth_failed') { + showBanner({ + severity: 'error', + message: 'Social sign-in failed or was cancelled. Please try again.', + scoped: true, + }); + // Remove only the error flag while preserving callbackUrl and other safe params + const params = new URLSearchParams(searchParams.toString()); + params.delete('error'); + const nextUrl = params.toString() ? `/login?${params.toString()}` : '/login'; + window.history.replaceState({}, '', nextUrl); + } + + return () => { + isMounted.current = false; + }; + }, [dispatch, searchParams, showBanner]); + + + const onSubmit = useCallback(async (values: z.infer) => { + // If we are on the email step, just validate the email and move forward + if (step === 'email') { + const isEmailValid = await form.trigger('userName'); + if (isEmailValid) { + setStep('password'); + } + return; + } + + // On the password step, ensure password is also validated + const isPasswordValid = await form.trigger('password'); + if (!isPasswordValid) return; + + // If the user didn't provide the captchaToken + if (hcaptchaEnabled && captchaToken === "") { + showBanner({ + severity: 'error', + message: 'Please complete the CAPTCHA before signing in.', + scoped: true + }); + return; + } + + setIsLoading(true); + // Record the login start time so the Home page can compute login duration + // for the post-login feedback toast. sessionStorage survives the redirect + // but is cleared automatically when the tab closes. + if (typeof window !== 'undefined') { + sessionStorage.setItem('vertex_login_start_ts', String(Date.now())); + } + + // Read preference BEFORE authentication to avoid timing issues + const lastModule = getLastActiveModule(values.userName); + const fallbackUrl = lastModule === 'admin' ? '/admin/networks' : '/home'; + const isAuthRouteCallback = + callbackUrl.startsWith('/login') || + callbackUrl.startsWith('/auth-error') || + callbackUrl.startsWith('/forgot-password'); + const redirectUrl = + callbackUrl && !isAuthRouteCallback ? callbackUrl : fallbackUrl; + + try { + const result = await signIn("credentials", { + redirect: false, + userName: values.userName, + password: values.password, + captchaToken: hcaptchaEnabled ? captchaToken : undefined, + callbackUrl: redirectUrl, + }); + + if (!isMounted.current) return; + + if (result?.ok) { + const session = await waitForSession(); + if (!session?.user) { + throw new Error("Could not confirm session. Please try again."); + } + showBanner({ severity: 'success', message: 'Welcome back!', scoped: true }); + + window.location.replace(result.url || redirectUrl); + } else { + let message = "Login failed. Please check your credentials."; + if (result?.error) { + if (result.error === 'CredentialsSignin') { + message = "Invalid email or password. Please check your credentials."; + } else if (result.error.toLowerCase().includes('fetch')) { + message = "Network error. Please check your connection and try again."; + } else { + message = result.error; + } + } + throw new Error(message); + } + } catch (error) { + if (!isMounted.current) return; + const message = getApiErrorMessage(error); + logger.error("Sign-in failed", { error: message }); + showBanner({ severity: 'error', message, scoped: true }); + setCaptchaToken(""); + captchaRef.current?.reset(); + setIsLoading(false); + } + }, [callbackUrl, waitForSession, step, form, showBanner, captchaToken, hcaptchaEnabled]); + + return ( +
+ {/* Sticky Topbar */} +
+
+
+ {`${vertexConfig.org.name} +
+ + {!isElectron && platform === 'win' && ( +
+ + + + + Download for Windows + +
+ )} +
+
+ + {/* Main Content Area */} +
+
+
+
+

+ Deploy devices, + Share your data +

+

+ Add your devices and stream live air quality data through {vertexConfig.org.name}'s open data channels. +

+
+ +
+ {step === 'email' && ( + + )} + +
+ { + e.preventDefault(); + onSubmit(form.getValues()); + }} + className="space-y-5" + > + + {step === 'email' ? ( + + ( + + )} + /> + + Continue with email + + + ) : ( + + +
+
+ + Signing in as + + + {form.getValues('userName')} + +
+ +
+ + ( +
+
+ + + Forgot password? + +
+ +
+ )} + /> + {hcaptchaEnabled ? ( + setCaptchaToken(token)} + onExpire={() => setCaptchaToken("")} + /> + ) : null} + + {isLoading ? "Signing in..." : "Login"} + +
+ )} +
+
+ + +
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+
+ +
+ ) +} diff --git a/src/vertex-template/app/not-found.tsx b/src/vertex-template/app/not-found.tsx new file mode 100644 index 0000000000..5883a0fc34 --- /dev/null +++ b/src/vertex-template/app/not-found.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { useAppSelector } from '@/core/redux/hooks'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import OopsIcon from '@/public/icons/Errors/OopsIcon'; + +/** + * 404 Not Found page component + * Automatically rendered by Next.js when a route is not found + */ +export default function NotFound() { + const router = useRouter(); + const activeGroup = useAppSelector((state) => state.user.activeGroup); + + const handleGoHome = () => { + if (activeGroup) { + router.push('/home'); + } else { + router.push('/'); + } + }; + + return ( +
+
+ {/* Icon */} +
+ +
+ + {/* Error Message */} +

+ Oops! Page Not Found +

+ +

+ The page you're looking for doesn't exist or has been moved. + Don't worry, let's get you back on track. +

+ + {/* Action Buttons */} +
+ + Go Home + + + window.history.back()} + > + Go Back + +
+ + {/* Additional Help */} +
+

+ If you believe this is an error, please{' '} + contact support. +

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/app/page.tsx b/src/vertex-template/app/page.tsx new file mode 100644 index 0000000000..ae2e8e482f --- /dev/null +++ b/src/vertex-template/app/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from 'next/navigation'; + +export default function Page() { + // Fallback: This code should theoretically not be reached because middleware + // handles the routing for "/" before this page is rendered. + // We keep this as a safety net. + redirect('/home'); +} \ No newline at end of file diff --git a/src/vertex-template/app/providers.tsx b/src/vertex-template/app/providers.tsx new file mode 100644 index 0000000000..383d25a7a3 --- /dev/null +++ b/src/vertex-template/app/providers.tsx @@ -0,0 +1,63 @@ +"use client"; +import { useEffect, useMemo } from "react"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { persistor, store } from "@/core/redux/store"; +import { Provider } from "react-redux"; +import { PersistGate } from "redux-persist/integration/react"; +import { AuthProvider } from "@/core/auth/authProvider"; +import dynamic from 'next/dynamic'; +import { ThemeProvider } from "@/components/theme-provider"; +import SessionLoadingState from "@/components/layout/loading/session-loading"; +import { QueryProvider } from "@/core/providers/query-provider"; +import { runClientCacheMaintenance } from "@/core/utils/clientCache"; +import { BannerProvider } from "@/context/banner-context"; + +const NetworkStatusBanner = dynamic( + () => import('@/components/features/network-status-banner'), + { ssr: false } +); + +import { Session } from "next-auth"; + +export default function Providers({ children, session }: { children: React.ReactNode, session: Session | null }) { + const cacheScope = useMemo(() => { + const user = session?.user as { id?: string; email?: string } | undefined; + const userId = typeof user?.id === "string" ? user.id.trim() : ""; + if (userId) return `id:${userId}`; + + const email = + typeof user?.email === "string" ? user.email.trim().toLowerCase() : ""; + if (email) return `email:${email}`; + + return "anon"; + }, [session?.user]); + + useEffect(() => { + runClientCacheMaintenance(); + }, []); + + return ( + + } persistor={persistor}> + + + + + {children} + + + {process.env.NODE_ENV !== "production" && ( + + )} + + + + + + ); +} diff --git a/src/vertex-template/app/types/clients.ts b/src/vertex-template/app/types/clients.ts new file mode 100644 index 0000000000..88db634699 --- /dev/null +++ b/src/vertex-template/app/types/clients.ts @@ -0,0 +1,26 @@ +import { UserDetails } from './users'; + + export interface AccessToken { + _id: string + permissions: string[] + scopes: string[] + expiredEmailSent: boolean + token: string + client_id: string + name: string + expires: string + createdAt: string + updatedAt: string + __v: number + } + + export interface Client { + _id: string + isActive: boolean + ip_addresses: string[] + name: string + client_secret: string + user: UserDetails + access_token: AccessToken + createdAt: string + } diff --git a/src/vertex-template/app/types/cohorts.ts b/src/vertex-template/app/types/cohorts.ts new file mode 100644 index 0000000000..77717c7276 --- /dev/null +++ b/src/vertex-template/app/types/cohorts.ts @@ -0,0 +1,106 @@ +export interface Cohort { + _id: string; + visibility: boolean; + cohort_tags: string[]; + cohort_codes: string[]; + name: string; + network: string; + groups: string[]; + numberOfDevices: number; + devices: Device[]; + createdAt?: string; +} + +export interface CohortsSummaryResponse { + success: boolean; + message: string; + meta: { + total: number; + limit: number; + skip: number; + page: number; + totalPages: number; + }; + cohorts: Cohort[]; +} + +export interface GroupCohortsResponse { + success: boolean; + message: string; + data: string[]; +} + +export interface PersonalUserCohortsResponse { + success: boolean; + message: string; + cohorts: string[]; +} + +export interface OriginalCohortResponse { + success: boolean; + message: string; + original_cohort: Cohort; +} + +interface Grid { + _id: string; + visibility: boolean; + name: string; + admin_level: string; + network: string; + long_name: string; + createdAt: string; + sites: Site[]; +} + +interface Site { + _id: string; + isOnline: boolean; + formatted_name: string; + location_name: string; + search_name: string; + city: string; + district: string; + county: string; + region: string; + country: string; + latitude: number; + longitude: number; + name: string; + approximate_latitude: number; + approximate_longitude: number; + generated_name: string; + data_provider: string; + description: string; + site_category: SiteCategory; + groups: string[]; + grids: Grid[]; + devices: Device[]; + airqlouds: unknown[]; + createdAt: string; + updatedAt?: string; +} + +interface SiteCategory { + tags: string[]; + area_name: string; + category: string; + highway: string; + landuse: string; + latitude: number; + longitude: number; + natural: string; + search_radius: number; + waterway: string; + } + +interface Device { + _id: string; + name: string; + network: string; + groups: string[]; + authRequired: boolean; + serial_number: string; + api_code: string; + long_name: string; +} \ No newline at end of file diff --git a/src/vertex-template/app/types/devices.ts b/src/vertex-template/app/types/devices.ts new file mode 100644 index 0000000000..6e8bf3fe2a --- /dev/null +++ b/src/vertex-template/app/types/devices.ts @@ -0,0 +1,461 @@ +export interface DeviceSite { + _id: string; + visibility: boolean; + grids: string[]; + isOnline: boolean; + location_name: string; + search_name: string; + group: string; + name: string; + data_provider: string; + site_category: { + tags: string[]; + area_name: string; + category: string; + highway: string; + landuse: string; + latitude: number; + longitude: number; + natural: string; + search_radius: number; + waterway: string; + }; + groups: string[]; + description?: string; + createdAt?: string; +} + +export interface DeviceGrid { + _id: string; + visibility: boolean; + name: string; + admin_level: string; + long_name: string; +} + +export interface DevicePreviousSite { + _id: string; + visibility?: boolean; + rawOnlineStatus?: boolean; + lastRawData?: string | null; + name?: string; + location_name?: string; + search_name?: string; + [key: string]: unknown; +} + +export interface DeviceCategoryHierarchy { + level: string; + category: string; + description: string; +} + +export interface DeviceCategoryRelationships { + type: string; + note: string; + belongs_to_equipment_category: string; + deployment_method: string; +} + +export interface DeviceCategories { + primary_category: string; + deployment_category: string; + is_mobile: boolean; + is_static: boolean; + is_lowcost: boolean; + is_bam: boolean; + is_gas: boolean; + all_categories: string[]; + category_hierarchy: DeviceCategoryHierarchy[]; + category_relationships: DeviceCategoryRelationships; +} + +export interface Device { + _id?: string; + id?: string; + name: string; + alias?: string; + mobility?: boolean; + network: string; + groups?: string[]; + serial_number: string; + authRequired?: boolean; + long_name?: string; + latitude?: number | undefined | null | string; + longitude?: number | undefined | null | string; + approximate_distance_in_km?: number; + bearing_in_radians?: number; + createdAt: string; + visibility?: boolean | undefined; + description?: string | undefined; + isPrimaryInLocation?: boolean; + nextMaintenance?: string; + deployment_date?: string; + mountType?: string; + isActive: boolean; + isOnline: boolean; + pictures?: unknown[]; + site_id?: string; + host_id?: string | null; + height?: number; + device_codes: string[]; + category: string; + device_categories?: DeviceCategories; + cohorts: unknown[]; + device_number?: number | undefined | string; + readKey?: string; + writeKey?: string; + phoneNumber?: string; + generation_version?: number | undefined | string; + generation_count?: number | undefined | string; + previous_sites?: Array; + grids?: DeviceGrid[]; + site?: DeviceSite[] | { + _id: string; + name: string; + }; + status?: "not deployed" | "deployed" | "recalled" | "online" | "offline"; + maintenance_status?: "good" | "due" | "overdue" | -1; + powerType?: "solar" | "alternator" | "mains"; + elapsed_time?: number; + // Additional properties for device ownership and status + owner_id?: string; + assigned_organization_id?: string; + claim_status?: "claimed" | "unclaimed"; + claimed_at?: string; + site_name?: string; // Optional site name for display purposes + [key: string]: unknown; + onlineStatusAccuracy?: { + accuracyPercentage?: number; + correctChecks?: number; + failurePercentage?: number; + lastCheck?: string; + lastCorrectCheck?: string; + lastSuccessfulUpdate?: string; + lastUpdate?: string; + successPercentage?: number; + successfulUpdates?: number; + totalAttempts?: number; + totalChecks?: number; + failedUpdates?: number; + incorrectChecks?: number; + lastFailureReason?: string; + lastIncorrectCheck?: string; + lastIncorrectReason?: string; + }; + api_code?: string; + lastActive?: string; + lastRawData?: string; + rawOnlineStatus?: boolean; + tags?: string[]; +} + +export interface PaginationMeta { + total: number; + totalResults: number; + limit: number; + skip: number; + page: number; + totalPages: number; + detailLevel: string; + usedCache: boolean; +} + +export interface DevicesSummaryResponse { + success: boolean; + message: string; + devices: Device[]; + meta: PaginationMeta; + cache_generated_at?: string; +} + +export interface DeviceAvailabilityResponse { + success: boolean; + message: string; + data: { + available: boolean; + status: "unclaimed" | "claimed" | "deployed"; + }; +} + +export interface DeviceClaimRequest { + device_name: string; + user_id: string; + claim_token?: string; + cohort_id?: string; +} + +export interface DeviceClaimResponse { + success: boolean; + message: string; + device: { + name: string; + long_name: string; + status: string; + claim_status: "claimed"; + claimed_at: string; + }; +} + +export interface BulkDeviceClaimItem { + device_name: string; + claim_token: string; +} + +export interface BulkDeviceClaimRequest { + user_id: string; + devices: BulkDeviceClaimItem[]; + cohort_id?: string; +} + +export interface BulkDeviceClaimResult { + device_name: string; + success?: boolean; + device?: { + name: string; + long_name: string; + status: string; + claim_status: "claimed"; + claimed_at: string; + }; + error?: string; +} + +export interface BulkDeviceClaimResponse { + success?: boolean; + message: string; + data: { + successful_claims: BulkDeviceClaimResult[]; + failed_claims: BulkDeviceClaimResult[]; + }; +} + +export interface MyDevicesResponse { + success: boolean; + message: string; + devices: Device[]; + total_devices: number; + deployed_devices: number; + deployed_devices_count?: number; +} + +export interface DeviceAssignmentRequest { + device_name: string; + organization_id: string; + user_id: string; +} + +export interface DeviceAssignmentResponse { + success: boolean; + message: string; + device: Device; +} + +export interface DeviceCreationResponse { + success: boolean; + message: string; + created_device: Device; +} + +export interface BulkImportDeviceResult { + serial_number: string; + long_name: string; + success: boolean; + created_device?: { + _id: string; + name: string; + network: string; + }; + error?: string; +} + +export interface BulkImportDeviceResponse { + success: boolean; + message: string; + imported: number; + failed: number; + total: number; + results: BulkImportDeviceResult[]; +} + +export interface DeviceUpdateGroupResponse { + success: boolean; + message: string; + updated_device?: Device; +} + +export interface MaintenanceLogData { + date: string; + tags: string[]; + description: string; + userName: string; + maintenanceType: "preventive" | "corrective"; + email: string; + firstName: string; + lastName: string; + user_id: string; +} + +export interface DecryptionRequest { + encrypted_key: string; + device_number: number; +} + +export interface DecryptedKeyResult { + encrypted_key: string; + device_number: string; + decrypted_key: string; +} + +export interface DecryptionResponse { + success: boolean; + message: string; + decrypted_keys: DecryptedKeyResult[]; +} + +export interface DevicePreparation { + device_name: string; + claim_token: string; + token_type: string; + qr_code_data: { + device_id: string; + claim_url: string; + platform: string; + token: string; + generated_at: string; + }; + qr_code_image: string; + label_data: { + device_name: string; + device_id: string; + claim_token: string; + instructions: string[]; + }; + shipping_prepared_at: string; +} + +export interface PrepareDeviceResponse { + success: boolean; + message: string; + device_preparation: DevicePreparation; +} + +export interface BulkPreparationResult { + device_name: string; + claim_token: string; + qr_code_data: unknown; + qr_code_image: string; +} + +export interface BulkPreparationFailure { + device_name: string; + error: string; +} + +export interface BulkPrepareResponse { + success: boolean; + message: string; + bulk_preparation_results: { + successful_preparations: BulkPreparationResult[]; + failed_preparations: BulkPreparationFailure[]; + summary: { + total_requested: number; + successful_count: number; + failed_count: number; + }; + }; +} + +export interface ShippingLabel { + device_name: string; + device_id: string; + device_long_name: string; + claim_token: string; + qr_code_image: string; + qr_code_data: unknown; + instructions: string[]; +} + +export interface GenerateLabelsResponse { + success: boolean; + message: string; + shipping_labels: { + labels: ShippingLabel[]; + total_labels: number; + }; +} + +export interface ShippingStatusDevice { + id?: string; + _id?: string; + name: string; + long_name: string; + claim_status: string; + claim_token: string | null; + shipping_prepared_at: string; + owner_id: string; + claimed_at: string; +} + +export interface ShippingStatusResponse { + success: boolean; + message: string; + shipping_status: { + devices: ShippingStatusDevice[]; + summary: { + total_devices: number; + prepared_for_shipping: number; + claimed_devices: number; + deployed_devices: number; + }; + categorized: { + prepared_for_shipping: unknown[]; + claimed_devices: unknown[]; + deployed_devices: unknown[]; + }; + }; +} + +export interface OrphanedDevice { + _id: string; + name: string; + long_name: string; + status: string; + isActive: boolean; + claim_status: string; + owner_id: string; + cohort_ids: string[]; + claimed_at: string; +} + +export interface OrphanedDevicesResponse { + success: boolean; + message: string; + devices: OrphanedDevice[]; + total_orphaned: number; + recommendation: string; +} + +export interface ShippingBatch { + _id: string; + batch_name: string; + device_count: number; + device_names: string[]; + createdAt: string; + updatedAt: string; +} + +export interface ShippingBatchesResponse { + success: boolean; + message: string; + batches: ShippingBatch[]; + meta: PaginationMeta; +} + +export interface ShippingBatchDetailsResponse { + success: boolean; + message: string; + batch: ShippingBatch & { + devices: ShippingStatusDevice[]; + }; +} diff --git a/src/vertex-template/app/types/export.ts b/src/vertex-template/app/types/export.ts new file mode 100644 index 0000000000..f55a618ba2 --- /dev/null +++ b/src/vertex-template/app/types/export.ts @@ -0,0 +1,17 @@ +import { Option } from "@/components/ui/multi-select" + +export type ExportType = 'sites' | 'devices' | 'airqlouds' | 'regions' + +export interface FormData { + startDateTime: string; + endDateTime: string; + sites?: Option[]; + devices?: Option[]; + cities?: Option[]; + regions?: Option[]; + pollutants?: Option[]; + frequency: string + fileType: string + outputFormat: string + dataType: string +} diff --git a/src/vertex-template/app/types/grids.ts b/src/vertex-template/app/types/grids.ts new file mode 100644 index 0000000000..b918fd882c --- /dev/null +++ b/src/vertex-template/app/types/grids.ts @@ -0,0 +1,40 @@ +import { Position } from "@/core/redux/slices/gridsSlice"; +import { Site } from "./sites"; +import { Group } from "./groups"; + +export interface CreateGrid { + name: string; + admin_level: string; + shape: { + type: "MultiPolygon" | "Polygon"; + coordinates: Position[][] | Position[][][]; + }; + network: string; +} + +export interface Grid { + _id: string; + visibility: boolean; + name: string; + admin_level: string; + network: string; + long_name: string; + createdAt: string; + sites: Site[]; + numberOfSites: number; + groups?: Group[]; +} + +export interface GridsSummaryResponse { + success: boolean; + message: string; + meta: { + total: number; + limit: number; + skip: number; + page: number; + totalPages: number; + nextPage?: string; + }; + grids: Grid[]; +} diff --git a/src/vertex-template/app/types/groups.ts b/src/vertex-template/app/types/groups.ts new file mode 100644 index 0000000000..25516da195 --- /dev/null +++ b/src/vertex-template/app/types/groups.ts @@ -0,0 +1,29 @@ +import { UserDetails } from "@/app/types/users"; + +export interface Group { + _id: string + grp_status: "ACTIVE" | "INACTIVE" + grp_profile_picture: string + grp_title: string + grp_description: string + grp_website: string + grp_industry: string + grp_country: string + grp_timezone: string + createdAt: string + numberOfGroupUsers: number + grp_users: UserDetails[] + grp_manager: UserDetails + cohorts?: string[] + organization_slug?: string + onboarding_checklist?: { + is_dismissed: boolean; + completed_steps: string[]; + } +} + +export interface CohortGroupsResponse { + success: boolean; + message: string; + groups: Group[]; +} diff --git a/src/vertex-template/app/types/layout.ts b/src/vertex-template/app/types/layout.ts new file mode 100644 index 0000000000..8bfcf6e828 --- /dev/null +++ b/src/vertex-template/app/types/layout.ts @@ -0,0 +1,5 @@ +import { ReactNode } from "react"; + +export interface LayoutProps { + children: ReactNode; +} diff --git a/src/vertex-template/app/types/roles.ts b/src/vertex-template/app/types/roles.ts new file mode 100644 index 0000000000..76a1dc4b74 --- /dev/null +++ b/src/vertex-template/app/types/roles.ts @@ -0,0 +1,39 @@ +export interface Permission { + _id: string; + permission: string; + description: string; + } + +export interface Group { + _id: string; + grp_status: string; + grp_profile_picture: string; + grp_title: string; + grp_description: string; + grp_website: string; + grp_industry: string; + grp_country: string; + grp_timezone: string; + grp_manager: string; + grp_manager_username: string; + grp_manager_firstname: string; + grp_manager_lastname: string; + createdAt: string; + updatedAt: string; + __v: number; + } + +export interface Role { + _id: string; + role_status: string; + role_name: string; + role_permissions: Permission[]; + group?: Group; + } + +export interface RolesResponse { + success: boolean; + message: string; + roles: Role[]; + } + \ No newline at end of file diff --git a/src/vertex-template/app/types/sites.ts b/src/vertex-template/app/types/sites.ts new file mode 100644 index 0000000000..7cbd22e0a1 --- /dev/null +++ b/src/vertex-template/app/types/sites.ts @@ -0,0 +1,104 @@ +import {Device as DeviceType} from "./devices"; + +export interface Site { + _id?: string; + nearest_tahmo_station?: { + id: number; + code: string | null; + longitude: number; + latitude: number; + timezone: string | null; + }; + images?: unknown[]; + groups?: string[]; + site_codes?: string[]; + site_tags?: string[]; + isOnline?: boolean; + rawOnlineStatus?: boolean; + formatted_name?: string; + location_name?: string; + search_name?: string; + parish?: string; + village?: string; + sub_county?: string; + city?: string; + district?: string; + county?: string; + region?: string; + country?: string; + latitude?: number; + longitude?: number; + name?: string; + network?: string; + approximate_latitude?: number; + approximate_longitude?: number; + bearing_in_radians?: number; + approximate_distance_in_km?: number; + lat_long?: string; + generated_name?: string; + altitude?: number; + data_provider?: string; + description?: string; + weather_stations?: Array<{ + code: string; + name: string; + country: string; + longitude: number; + latitude: number; + timezone: string; + distance: number; + _id: string; + }>; + createdAt?: string; + lastActive?: string; + grids?: Array<{ + _id: string; + name: string; + admin_level: string; + visibility: boolean; + }>; + devices?: DeviceType[]; + airqlouds?: unknown[]; + site_category?: { + tags: string[]; + area_name: string; + category: string; + highway: string; + landuse: string; + latitude: number; + longitude: number; + natural: string; + search_radius: number; + waterway: string; + }; + geometry?: { + bounds: { + northeast: { lat: number; lng: number }; + southwest: { lat: number; lng: number }; + }; + location: { lat: number; lng: number }; + location_type: string; + viewport: { + northeast: { lat: number; lng: number }; + southwest: { lat: number; lng: number }; + }; + }; + landform_270?: number; + landform_90?: number; + aspect?: number; + distance_to_nearest_road?: number; + distance_to_nearest_secondary_road?: number; + distance_to_nearest_unclassified_road?: number; + distance_to_nearest_residential_road?: number; + share_links?: Record; +} + +export interface SiteDevice { + name: string; + description?: string; + site?: string; + isPrimary: boolean; + isCoLocated: boolean; + registrationDate: string; + deploymentStatus: "Deployed" | "Pending" | "Removed"; +} diff --git a/src/vertex-template/app/types/userStats.ts b/src/vertex-template/app/types/userStats.ts new file mode 100644 index 0000000000..131cbda9b3 --- /dev/null +++ b/src/vertex-template/app/types/userStats.ts @@ -0,0 +1,25 @@ +export interface User { + userName?: string + email?: string + firstName?: string + lastName?: string + _id?: string + } + + export interface UserCategory { + number: number + details: User[] + } + + export interface UserStats { + users: UserCategory + active_users: UserCategory + api_users: UserCategory + } + + export interface UserStatsResponse { + success: boolean + message: string + users_stats: UserStats + } + \ No newline at end of file diff --git a/src/vertex-template/app/types/users.ts b/src/vertex-template/app/types/users.ts new file mode 100644 index 0000000000..6a49064b22 --- /dev/null +++ b/src/vertex-template/app/types/users.ts @@ -0,0 +1,130 @@ +export interface Permission { + _id: string; + permission: string; + network_id?: string; + description?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface Role { + _id: string; + role_name: string; + role_permissions: Permission[]; +} + +export interface Network { + net_name: string; + _id: string; + role: Role; + userType: string; + createdAt?: string; + status?: string; +} + +export interface Client { + _id: string; + name: string; + user_id: string; + client_secret: string; + createdAt: string; + updatedAt: string; + isActive: boolean; +} + +export interface Group { + grp_title: string; + _id: string; + createdAt: string; + status: string; + role: Role; + userType: string; + onboarding_checklist?: { + is_dismissed: boolean; + completed_steps: string[]; + }; +} + +export interface UserDetails { + _id: string; + firstName: string; + lastName: string; + lastLogin: string; + isActive?: boolean; + loginCount?: number; + userName: string; + email: string; + verified?: boolean; + analyticsVersion?: number; + country?: string | null; + privilege?: string; + website?: string | null; + category?: string | null; + organization?: string; + long_organization?: string; + rateLimit: number | null; + jobTitle?: string | null; + description?: string | null; + timezone?: string | null; + profilePicture: string | null; + phoneNumber: string | null; + updatedAt: string; + networks?: Network[]; + clients?: Client[]; + groups?: Group[]; + permissions?: Permission[]; + createdAt: string; + my_networks?: string[]; + my_groups?: string[]; + group_ids?: string[]; + cohort_ids?: string[]; + iat?: number; +} + +export interface LoginResponse { + success: boolean; + message: string; + token: string; + _id: string; + userName: string; + email: string; +} + +export interface UserDetailsResponse { + success: boolean; + message: string; + users: UserDetails[]; +} + +export interface LoginCredentials { + userName: string; + password: string; + captchaToken?: string; +} + +export interface DecodedToken { + _id: string; + firstName: string; + lastName: string; + userName: string; + email: string; + organization: string; + long_organization: string; + privilege: string; + country: string | null; + profilePicture: string | null; + description: string | null; + timezone: string | null; + phoneNumber: string | null; + createdAt: string; + updatedAt: string; + rateLimit: number | null; + lastLogin: string; + iat: number; + exp?: number; +} + +export interface CurrentRole { + role_name: string; + permissions: string[]; +} diff --git a/src/vertex-template/components.json b/src/vertex-template/components.json new file mode 100644 index 0000000000..a2a87a40b0 --- /dev/null +++ b/src/vertex-template/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "core": "@/core" + }, + "iconLibrary": "lucide" +} diff --git a/src/vertex-template/components/features/auth/cookie-info-banner.tsx b/src/vertex-template/components/features/auth/cookie-info-banner.tsx new file mode 100644 index 0000000000..3c67976380 --- /dev/null +++ b/src/vertex-template/components/features/auth/cookie-info-banner.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { COOKIE_POLICY_URL } from '@/lib/envConstants'; +import ReusableButton from '@/components/shared/button/ReusableButton'; + +export function CookieInfoBanner() { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const cookiesAccepted = localStorage.getItem('vertex_cookies_accepted'); + if (!cookiesAccepted) { + setIsVisible(true); + } + }, []); + + const handleDismiss = () => { + localStorage.setItem('vertex_cookies_accepted', 'true'); + setIsVisible(false); + }; + + if (!isVisible) { + return null; + } + + return ( +
+
+

+ AirQo uses cookies to deliver and enhance the quality of its services and to analyze traffic. + {' '} + + Learn more about our cookie policy + +

+ + OK, got it + +
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/auth/social-auth-section.tsx b/src/vertex-template/components/features/auth/social-auth-section.tsx new file mode 100644 index 0000000000..f00f7c115c --- /dev/null +++ b/src/vertex-template/components/features/auth/social-auth-section.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { FaGithub, FaLinkedinIn } from 'react-icons/fa'; +import { FaXTwitter } from 'react-icons/fa6'; +import { FcGoogle } from 'react-icons/fc'; +import { + buildOAuthInitiationUrl, + getLastUsedOAuthProvider, + resolveOAuthRedirectAfterUrl, + setLastUsedOAuthProvider, + type SupportedSocialAuthProvider, +} from '@/core/auth/oauth-session'; +import { cn } from '@/lib/utils'; +import { useBanner } from '@/context/banner-context'; +import { getLastActiveModule } from '@/core/utils/userPreferences'; + +interface SocialAuthSectionProps { + mode: 'login' | 'register'; + disabled?: boolean; + className?: string; + callbackUrl?: string | null; +} + +const SOCIAL_PROVIDERS: Array<{ + provider: SupportedSocialAuthProvider; + label: string; + Icon: React.ComponentType<{ className?: string }>; + iconClassName?: string; +}> = [ + { + provider: 'google', + label: 'Google', + Icon: FcGoogle, + }, + { + provider: 'github', + label: 'GitHub', + Icon: FaGithub, + iconClassName: 'text-slate-950 dark:text-white', + }, + { + provider: 'linkedin', + label: 'LinkedIn', + Icon: FaLinkedinIn, + iconClassName: 'text-[#0A66C2]', + }, + { + provider: 'twitter', + label: 'X', + Icon: FaXTwitter, + iconClassName: 'text-slate-950 dark:text-white', + }, +]; + +export default function SocialAuthSection({ + mode, + disabled = false, + className, + callbackUrl, +}: SocialAuthSectionProps) { + const { showBanner } = useBanner(); + const actionLabel = mode === 'register' ? 'Continue with' : 'Sign in with'; + const lastModule = getLastActiveModule(); + const fallbackUrl = lastModule === 'admin' ? '/admin/networks' : '/home'; + const redirectPath = callbackUrl || fallbackUrl; + const [lastUsedProvider, setLastUsedProvider] = + useState(null); + + useEffect(() => { + setLastUsedProvider(getLastUsedOAuthProvider()); + }, []); + + const orderedProviders = lastUsedProvider + ? [ + ...SOCIAL_PROVIDERS.filter( + ({ provider }) => provider === lastUsedProvider + ), + ...SOCIAL_PROVIDERS.filter( + ({ provider }) => provider !== lastUsedProvider + ), + ] + : SOCIAL_PROVIDERS; + + const handleSocialAuth = useCallback( + (provider: SupportedSocialAuthProvider) => { + if (typeof window === 'undefined' || disabled) return; + + const redirectAfter = resolveOAuthRedirectAfterUrl(redirectPath); + const queryParams: Record = {}; + + if (redirectAfter) { + queryParams.redirect_after = redirectAfter; + } + + + try { + setLastUsedOAuthProvider(provider); + window.location.replace(buildOAuthInitiationUrl(provider, queryParams)); + } catch (error) { + showBanner({ + severity: 'error', + message: `Social sign-in unavailable. Please try again in a moment.`, + scoped: true, + }); + console.error(`Failed to start ${provider} OAuth flow:`, error); + } + }, + [disabled, redirectPath, showBanner] + ); + + return ( +
+
+ {orderedProviders.map(({ provider, label, Icon, iconClassName }) => { + const isLastUsed = provider === lastUsedProvider; + + return ( + + ); + })} +
+ +
+ + + Or + + +
+
+ ); +} diff --git a/src/vertex-template/components/features/claim/DeviceEntryRow.tsx b/src/vertex-template/components/features/claim/DeviceEntryRow.tsx new file mode 100644 index 0000000000..d991fde523 --- /dev/null +++ b/src/vertex-template/components/features/claim/DeviceEntryRow.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React from 'react'; +import { AqXClose } from '@airqo/icons-react'; +import ReusableInputField from '@/components/shared/inputfield/ReusableInputField'; + +interface DeviceEntryRowProps { + deviceName: string; + claimToken: string; + onDeviceNameChange: (value: string) => void; + onClaimTokenChange: (value: string) => void; + onRemove: () => void; + deviceNameError?: string; + claimTokenError?: string; + index: number; + showRemove?: boolean; +} + +export const DeviceEntryRow: React.FC = ({ + deviceName, + claimToken, + onDeviceNameChange, + onClaimTokenChange, + onRemove, + deviceNameError, + claimTokenError, + index, + showRemove = true, +}) => { + return ( +
+
+ + {index + 1} + +
+
+ onDeviceNameChange(e.target.value)} + error={deviceNameError} + required + /> + onClaimTokenChange(e.target.value)} + error={claimTokenError} + required + /> +
+ {showRemove && ( + + )} +
+ ); +}; diff --git a/src/vertex-template/components/features/claim/FileUploadParser.tsx b/src/vertex-template/components/features/claim/FileUploadParser.tsx new file mode 100644 index 0000000000..a6cb358057 --- /dev/null +++ b/src/vertex-template/components/features/claim/FileUploadParser.tsx @@ -0,0 +1,352 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import { AqUploadCloud02 } from '@airqo/icons-react'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import ReusableToast from '@/components/shared/toast/ReusableToast'; + +export interface ParsedFileData { + headers: string[]; + data: unknown[][]; + fileName: string; +} + +interface BulkClaimColumnMapperProps { + filePreview: ParsedFileData; + onConfirm: (deviceNameColumn: number, claimTokenColumn: number) => void; + onCancel: () => void; +} + +export const BulkClaimColumnMapper: React.FC = ({ + filePreview, + onConfirm, + onCancel, +}) => { + const [deviceNameColumn, setDeviceNameColumn] = useState(() => { + // Auto-detect device name column + const index = filePreview.headers.findIndex(h => + /device.*name|name|device.*id|device/i.test(h.toLowerCase()) + ); + return index !== -1 ? index : 0; + }); + + const [claimTokenColumn, setClaimTokenColumn] = useState(() => { + // Auto-detect claim token column + const index = filePreview.headers.findIndex(h => + /claim.*token|token|claim/i.test(h.toLowerCase()) + ); + return index !== -1 ? index : filePreview.headers.length > 1 ? 1 : 0; + }); + + const handleConfirm = () => { + if (deviceNameColumn === claimTokenColumn) { + ReusableToast({ + message: 'Device name and claim token must be in different columns', + type: 'ERROR', + }); + return; + } + onConfirm(deviceNameColumn, claimTokenColumn); + }; + + return ( +
+
+
+

+ Map Columns for Device Claiming +

+

+ File: {filePreview.fileName} +

+
+ +
+

+ Please select which columns contain the device names and claim tokens: +

+ + {/* Column Selectors */} +
+
+ + +
+
+ + +
+
+ + {/* Preview Table */} +
+ + + + {filePreview.headers.map((header, index) => ( + + ))} + + + + {filePreview.data.slice(1, 6).map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {header} + {index === deviceNameColumn && ( + (Device) + )} + {index === claimTokenColumn && ( + (Token) + )} +
+ {String(cell || '')} +
+
+

+ Showing first 5 rows as preview +

+
+ +
+ + Cancel + + Import Devices +
+
+
+ ); +}; + +interface FileUploadParserProps { + onFilesParsed: (devices: Array<{ device_name: string; claim_token: string }>) => void; + variant?: 'button' | 'dropzone'; +} + +export const FileUploadParser: React.FC = ({ onFilesParsed, variant = 'button' }) => { + const [isImporting, setIsImporting] = useState(false); + const [filePreview, setFilePreview] = useState(null); + const fileInputRef = useRef(null); + + const handleFileImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const fileExtension = file.name.split('.').pop()?.toLowerCase(); + if (!['csv', 'xlsx', 'xls'].includes(fileExtension || '')) { + ReusableToast({ + message: 'Invalid file format. Please upload a CSV or Excel file.', + type: 'ERROR', + }); + e.target.value = ''; + return; + } + + if (file.size > 5 * 1024 * 1024) { + ReusableToast({ message: 'File too large. Maximum size is 5MB.', type: 'ERROR' }); + e.target.value = ''; + return; + } + + setIsImporting(true); + + try { + let parsedData: unknown[][] = []; + let headers: string[] = []; + + if (fileExtension === 'csv') { + const Papa = (await import('papaparse')).default; + Papa.parse(file, { + complete: (results) => { + parsedData = results.data as unknown[][]; + if (parsedData.length > 0) { + const firstRow = parsedData[0]; + headers = (firstRow as unknown[]).map((cell: unknown, index: number) => { + const cellStr = String(cell || '').trim(); + return cellStr || `Column ${index + 1}`; + }); + setFilePreview({ headers, data: parsedData, fileName: file.name }); + } + setIsImporting(false); + }, + error: (error) => { + ReusableToast({ + message: `Error parsing CSV: ${error.message}`, + type: 'ERROR', + }); + setIsImporting(false); + }, + }); + } else { + const XLSX = await import('xlsx'); + const reader = new FileReader(); + reader.onload = (evt) => { + const bstr = evt.target?.result; + const workbook = XLSX.read(bstr, { type: 'binary' }); + const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; + parsedData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }) as unknown[][]; + if (parsedData.length > 0) { + const firstRow = parsedData[0]; + headers = (firstRow as unknown[]).map((cell: unknown, index: number) => { + const cellStr = String(cell || '').trim(); + return cellStr || `Column ${index + 1}`; + }); + setFilePreview({ headers, data: parsedData, fileName: file.name }); + } + setIsImporting(false); + }; + reader.onerror = () => { + ReusableToast({ message: 'Error reading Excel file', type: 'ERROR' }); + setIsImporting(false); + }; + reader.readAsBinaryString(file); + } + } catch { + ReusableToast({ + message: 'Error importing file. Please ensure papaparse and xlsx libraries are installed.', + type: 'ERROR', + }); + setIsImporting(false); + } + + e.target.value = ''; + }; + + const handleConfirmImport = (deviceNameColumn: number, claimTokenColumn: number) => { + if (!filePreview) return; + + const devices = filePreview.data + .slice(1) + .map((row) => { + const deviceName = row[deviceNameColumn]; + const claimToken = row[claimTokenColumn]; + return { + device_name: + typeof deviceName === 'string' + ? deviceName.trim() + : String(deviceName || '').trim(), + claim_token: + typeof claimToken === 'string' + ? claimToken.trim() + : String(claimToken || '').trim(), + }; + }) + .filter((device) => device.device_name.length > 0 && device.claim_token.length > 0); + + if (devices.length === 0) { + ReusableToast({ + message: 'No valid devices found in the selected columns', + type: 'ERROR', + }); + return; + } + + onFilesParsed(devices); + setFilePreview(null); + }; + + const handleCancelImport = () => { + setFilePreview(null); + }; + + return ( + <> + {filePreview && ( + + )} + + + + {variant === 'dropzone' ? ( +
!isImporting && fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center cursor-pointer transition-colors w-full ${isImporting + ? 'border-gray-200 bg-gray-50 opacity-50 cursor-not-allowed' + : 'border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 hover:border-blue-500 dark:hover:border-blue-400' + }`} + > +
+ +
+ + {isImporting ? 'Processing file...' : 'Click to upload or drag and drop'} + + + CSV or Excel files (max 5MB) + +
+ ) : ( + fileInputRef.current?.click()} + disabled={isImporting} + loading={isImporting} + variant="outlined" + > + Import File + + )} + + ); +}; diff --git a/src/vertex-template/components/features/claim/claim-device-modal.tsx b/src/vertex-template/components/features/claim/claim-device-modal.tsx new file mode 100644 index 0000000000..86e8485d85 --- /dev/null +++ b/src/vertex-template/components/features/claim/claim-device-modal.tsx @@ -0,0 +1,984 @@ +// ClaimDeviceModal.tsx +'use client'; + +import React, { useState, useCallback, useEffect } from 'react'; +import { Loader2, AlertCircle } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useQueryClient } from '@tanstack/react-query'; +import { useSession } from 'next-auth/react'; +import ReusableDialog from '@/components/shared/dialog/ReusableDialog'; + +import { useAppSelector } from '@/core/redux/hooks'; +import { useRouter } from 'next/navigation'; +import { useClaimDevice, useBulkClaimDevices } from '@/core/hooks/useDevices'; +import { useUserContext } from '@/core/hooks/useUserContext'; +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; +import { + useGroupCohorts, + useVerifyCohort, + useAssignCohortsToGroup, + useAssignCohortsToUser, +} from '@/core/hooks/useCohorts'; +import dynamic from 'next/dynamic'; + +const StepLoader = () => ( +
+
+
+); + +const BulkClaimResults = dynamic(() => import('./steps/BulkClaimResults').then(mod => mod.BulkClaimResults), { loading: StepLoader }); +const CohortImportStep = dynamic(() => import('./steps/CohortImportStep'), { loading: StepLoader }); +const ManualInputStep = dynamic(() => import('./steps/ManualInputStep'), { loading: StepLoader }); +const QRScanStep = dynamic(() => import('./steps/QRScanStep'), { loading: StepLoader }); +const SuccessStep = dynamic(() => import('./steps/SuccessStep'), { loading: StepLoader }); +const BulkConfirmationStep = dynamic(() => import('./steps/ConfirmationSteps').then(mod => mod.BulkConfirmationStep), { loading: StepLoader }); +const CohortConfirmStep = dynamic(() => import('./steps/ConfirmationSteps').then(mod => mod.CohortConfirmStep), { loading: StepLoader }); +const ConfirmationStep = dynamic(() => import('./steps/ConfirmationSteps').then(mod => mod.ConfirmationStep), { loading: StepLoader }); +const MethodSelectStep = dynamic(() => import('./steps/MethodSelectStep'), { loading: StepLoader }); +const BulkInputStep = dynamic(() => import('./steps/BulkInputStep'), { loading: StepLoader }); + +import { parseQRCode } from './utils'; + +// ============================================================ +// MODES +// ============================================================ + +export type ClaimFlowMode = 'guided' | 'fast'; + +// ============================================================ +// TYPES +// ============================================================ + +type DialogPrimaryAction = NonNullable< + React.ComponentProps['primaryAction'] +>; + +type DialogSecondaryAction = NonNullable< + React.ComponentProps['secondaryAction'] +>; + +export type FlowStep = + | 'method-select' + | 'manual-input' + | 'qr-scan' + | 'confirmation' + | 'claiming' + | 'success' + | 'bulk-input' + | 'bulk-confirmation' + | 'bulk-claiming' + | 'bulk-results' + | 'cohort-import' + | 'cohort-confirm' + | 'assigning-cohort'; + +export interface ClaimedDeviceInfo { + deviceId: string; + deviceName: string; + cohortId: string; +} + +export interface ClaimDeviceModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess?: (deviceInfo: ClaimedDeviceInfo & { isCohortImport?: boolean }) => void; + initialStep?: FlowStep; + mode?: ClaimFlowMode; +} + +export interface BulkDevice { + device_name: string; + claim_token: string; +} + +export interface VerifiedCohort { + id: string; + name: string; +} + +// ============================================================ +// FORM SCHEMA +// ============================================================ + +const claimDeviceSchema = z.object({ + device_id: z + .string() + .min(1, 'Device ID is required') + .min(3, 'Device ID must be at least 3 characters') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Device ID can only contain letters, numbers, underscores, and hyphens', + ), + claim_token: z + .string() + .min(1, 'Claim token is required') + .min(4, 'Claim token must be at least 4 characters'), +}); + +export type ClaimDeviceFormData = z.infer; + +// ============================================================ +// SHARED COMPONENTS +// ============================================================ + +export const ErrorAlert = ({ message }: { message: string }) => ( +
+ + {message} +
+); + +const LoadingSpinner = ({ + title, + subtitle, +}: { + title: string; + subtitle?: string; +}) => ( +
+ +
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+); + +// ============================================================ +// MAIN COMPONENT +// ============================================================ + +const ClaimDeviceModal: React.FC = ({ + isOpen, + onClose, + onSuccess, + initialStep = 'method-select', + mode = 'fast', +}) => { + const isGuidedMode = mode === 'guided'; + + const router = useRouter(); + const queryClient = useQueryClient(); + + const { data: session } = useSession(); + const user = useAppSelector(state => state.user.userDetails); + + const { isPersonalContext, isExternalOrg, activeGroup, userScope } = + useUserContext(); + + const userId = (session?.user as { id?: string })?.id || user?._id; + + // ========================================================== + // Cohorts + // ========================================================== + + const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, { + enabled: !isGuidedMode && !isPersonalContext && !!activeGroup?._id, + }); + + const defaultCohort = groupCohortIds?.[0] || null; + + // ========================================================== + // Mutations + // ========================================================== + + const { + mutate: claimDevice, + isPending, + isSuccess, + data: claimData, + error: claimError, + } = useClaimDevice(); + + const { + mutate: bulkClaimDevices, + isPending: isBulkPending, + isSuccess: isBulkSuccess, + data: bulkClaimData, + error: bulkClaimError, + } = useBulkClaimDevices(); + + const { mutateAsync: verifyCohort } = useVerifyCohort(); + const { mutate: assignCohortsToGroup } = useAssignCohortsToGroup(); + const { mutate: assignCohortsToUser } = useAssignCohortsToUser(); + + // ========================================================== + // State + // ========================================================== + + const [step, setStep] = useState(initialStep); + const [error, setError] = useState(null); + const [bulkDevices, setBulkDevices] = useState([]); + const [pendingSingleClaim, setPendingSingleClaim] = useState<{ + deviceId: string; + claimToken: string; + } | null>(null); + const [cohortIdInput, setCohortIdInput] = useState(''); + + // Tracks which step the user came from before confirmation, + // so the Cancel/Back button returns to the right place. + const [previousStep, setPreviousStep] = useState('method-select'); + + const [isImportingCohort, setIsImportingCohort] = useState(false); + const [isCohortAssignmentSuccess, setIsCohortAssignmentSuccess] = + useState(false); + const [verifiedCohort, setVerifiedCohort] = useState( + null, + ); + + // ========================================================== + // Form + // ========================================================== + + const formMethods = useForm({ + resolver: zodResolver(claimDeviceSchema), + defaultValues: { + device_id: '', + claim_token: '', + }, + }); + + // ========================================================== + // Cache Helpers + // ========================================================== + + const invalidatePersonalCaches = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['personalUserCohorts', userId] }); + queryClient.invalidateQueries({ queryKey: ['myDevices'] }); + queryClient.invalidateQueries({ queryKey: ['deviceCount', 'personal'] }); + }, [queryClient, userId]); + + const invalidateGroupCaches = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: ['groupCohorts', activeGroup?._id], + }); + queryClient.invalidateQueries({ + queryKey: ['deviceCount', activeGroup?._id], + }); + queryClient.invalidateQueries({ queryKey: ['devices'] }); + }, [queryClient, activeGroup?._id]); + + // ========================================================== + // Reset + // ========================================================== + + const resetState = useCallback(() => { + formMethods.reset(); + setStep('method-select'); + setError(null); + setBulkDevices([]); + setPendingSingleClaim(null); + setCohortIdInput(''); + setPreviousStep('method-select'); + setIsImportingCohort(false); + setIsCohortAssignmentSuccess(false); + setVerifiedCohort(null); + }, [formMethods]); + + // Always go through resetState so isCohortAssignmentSuccess etc. are cleared + const handleClose = useCallback(() => { + resetState(); + onClose(); + }, [resetState, onClose]); + + // ========================================================== + // Effects + // ========================================================== + + useEffect(() => { + if (isOpen) { + setStep(initialStep); + setError(null); + formMethods.reset(); + } + }, [isOpen, initialStep, formMethods]); + + // Single claim: success + useEffect(() => { + if (!isSuccess || !claimData) return; + + if (isPersonalContext) { + invalidatePersonalCaches(); + } else { + invalidateGroupCaches(); + } + + setStep('success'); + + if (onSuccess && claimData.device) { + onSuccess({ + deviceId: claimData.device.name, + deviceName: claimData.device.long_name || claimData.device.name, + cohortId: '', + }); + } + }, [ + isSuccess, + claimData, + invalidatePersonalCaches, + invalidateGroupCaches, + isPersonalContext, + onSuccess, + ]); + + // Single claim: error + useEffect(() => { + if (claimError) { + setError( + claimError.message || 'Failed to add airqo device. Please try again.', + ); + setStep('manual-input'); + } + }, [claimError]); + + // Single claim: loading + useEffect(() => { + if (isPending && step !== 'claiming') { + setStep('claiming'); + } + }, [isPending, step]); + + // Bulk claim: success + useEffect(() => { + if (isBulkSuccess && bulkClaimData) { + setStep('bulk-results'); + + if (isPersonalContext) { + invalidatePersonalCaches(); + } else { + invalidateGroupCaches(); + } + } + }, [ + isBulkSuccess, + bulkClaimData, + invalidatePersonalCaches, + invalidateGroupCaches, + isPersonalContext, + ]); + + // Bulk claim: error + useEffect(() => { + if (bulkClaimError) { + setError( + bulkClaimError.message || 'Failed to add airqo devices. Please try again.', + ); + setStep('bulk-input'); + } + }, [bulkClaimError]); + + // Bulk claim: loading + useEffect(() => { + if (isBulkPending && step !== 'bulk-claiming') { + setStep('bulk-claiming'); + } + }, [isBulkPending, step]); + + // ========================================================== + // Single Claim Handlers + // ========================================================== + + const handleClaimDevice = (deviceId: string, claimToken: string) => { + if (!userId) { + setError('User session not available. Please try again.'); + return; + } + + // GUIDED MODE — claim only, no cohort assignment + if (isGuidedMode) { + setError(null); + claimDevice({ + device_name: deviceId, + user_id: userId, + claim_token: claimToken, + }); + return; + } + + // FAST MODE — claim + optional auto-cohort assignment + if (!isPersonalContext && !defaultCohort) { + setError('No cohorts found. Please create a cohort first.'); + return; + } + + setError(null); + claimDevice({ + device_name: deviceId, + user_id: userId, + claim_token: claimToken, + ...(defaultCohort && { cohort_id: defaultCohort }), + }); + }; + + const onManualSubmit = (data: ClaimDeviceFormData) => { + setPendingSingleClaim({ + deviceId: data.device_id, + claimToken: data.claim_token, + }); + setPreviousStep('manual-input'); + setStep('confirmation'); + }; + + const handleConfirmSingleClaim = () => { + if (isPending || !pendingSingleClaim) return; + handleClaimDevice( + pendingSingleClaim.deviceId, + pendingSingleClaim.claimToken, + ); + }; + + const handleQRScan = (result: string) => { + const parsed = parseQRCode(result); + + if (!parsed) { + setError('Invalid QR code format. Please try manual entry.'); + setStep('manual-input'); + return; + } + + setPendingSingleClaim({ + deviceId: parsed.deviceId, + claimToken: parsed.claimToken, + }); + setPreviousStep('qr-scan'); + setStep('confirmation'); + }; + + // ========================================================== + // Bulk Handlers (FAST MODE ONLY) + // ========================================================== + + /** Add a blank device row so the user can type into it. */ + const handleBulkAddDevice = () => { + setBulkDevices(prev => [...prev, { device_name: '', claim_token: '' }]); + }; + + /** Remove the device row at the given index. */ + const handleBulkRemoveDevice = (index: number) => { + setBulkDevices(prev => prev.filter((_, i) => i !== index)); + }; + + /** + * Update a single field on a specific device row. + * `field` is either 'device_name' or 'claim_token'. + */ + const handleBulkDeviceChange = ( + index: number, + field: keyof BulkDevice, + value: string, + ) => { + setBulkDevices(prev => + prev.map((device, i) => + i === index ? { ...device, [field]: value } : device, + ), + ); + }; + + /** + * Replace the current device list with devices parsed from an + * imported file (CSV, JSON, etc.). The BulkInputStep is responsible + * for parsing; it hands us a ready-to-use BulkDevice array. + */ + const handleBulkFileImport = (devices: BulkDevice[]) => { + setError(null); + setBulkDevices(devices); + }; + + /** Clear all device rows from the bulk list. */ + const handleBulkClear = () => { + setBulkDevices([]); + setError(null); + }; + + const handleBulkSubmit = () => { + if (!userId) { + setError('User session not available. Please try again.'); + return; + } + + const valid = bulkDevices.filter( + d => d.device_name.trim() && d.claim_token.trim(), + ); + + if (valid.length === 0) { + setError('Please add at least one device with both name and token.'); + return; + } + + setError(null); + setStep('bulk-confirmation'); + }; + + const handleConfirmBulkClaim = () => { + if (!userId) return; + + const valid = bulkDevices.filter( + d => d.device_name.trim() && d.claim_token.trim(), + ); + + bulkClaimDevices({ + user_id: userId, + devices: valid, + ...(defaultCohort && { cohort_id: defaultCohort }), + }); + }; + + // ========================================================== + // Cohort Assignment (FAST MODE ONLY) + // ========================================================== + + const handleVerifyCohort = async () => { + const input = cohortIdInput.trim(); + + if (!input) { + setError('Please enter a valid Cohort ID'); + return; + } + + setError(null); + setIsImportingCohort(true); + + try { + const result = await verifyCohort(input); + + if (!result.success) { + setError(result.message || 'Invalid Cohort ID'); + return; + } + + const cohortName = + (result as { data?: { name?: string } }).data?.name || + result?.cohort?.name || + ''; + + setVerifiedCohort({ id: input, name: cohortName || input }); + setStep('cohort-confirm'); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to verify Cohort ID', + ); + } finally { + setIsImportingCohort(false); + } + }; + + const handleConfirmCohortImport = () => { + if (!verifiedCohort) { + setError('Session expired. Please verify the cohort again.'); + return; + } + + setStep('assigning-cohort'); + + if (isExternalOrg && activeGroup?._id) { + assignCohortsToGroup( + { groupId: activeGroup._id, cohortIds: [verifiedCohort.id] }, + { + onSuccess: () => { + invalidateGroupCaches(); + if (onSuccess) { + onSuccess({ + deviceId: '', + deviceName: '', + cohortId: verifiedCohort.id, + isCohortImport: true, + }); + } + setTimeout(() => { + setIsCohortAssignmentSuccess(true); + setStep('success'); + }, 1500); + }, + onError: err => { + setError(getApiErrorMessage(err)); + setStep('cohort-import'); + }, + }, + ); + return; + } + + if (isPersonalContext && userId) { + assignCohortsToUser( + { userId, cohortIds: [verifiedCohort.id] }, + { + onSuccess: () => { + invalidatePersonalCaches(); + if (onSuccess) { + onSuccess({ + deviceId: '', + deviceName: '', + cohortId: verifiedCohort.id, + isCohortImport: true, + }); + } + setTimeout(() => { + setIsCohortAssignmentSuccess(true); + setStep('success'); + }, 1500); + }, + onError: err => { + setError(getApiErrorMessage(err)); + setStep('cohort-import'); + }, + }, + ); + } + }; + + // ========================================================== + // Dialog Config + // ========================================================== + + const getDialogConfig = () => { + const base = { + title: 'Add AirQo Device', + showFooter: false, + showCloseButton: true, + preventBackdropClose: false, + primaryAction: undefined as DialogPrimaryAction | undefined, + secondaryAction: undefined as DialogSecondaryAction | undefined, + }; + + const back = (to: FlowStep): DialogSecondaryAction => ({ + label: 'Back', + onClick: () => setStep(to), + variant: 'outline', + }); + + switch (step) { + case 'method-select': + return base; + + case 'qr-scan': + return { + ...base, + title: 'Scan QR Code', + showFooter: true, + secondaryAction: back('method-select'), + }; + + case 'manual-input': + return { + ...base, + showFooter: true, + primaryAction: { + label: isPending ? 'Adding...' : 'Add AirQo Device', + onClick: formMethods.handleSubmit(onManualSubmit), + disabled: isPending, + }, + // Back goes to QR scan in fast mode, or method-select in guided mode. + // If the user came from QR scan (previousStep), honour that; otherwise + // fall back to method-select. + secondaryAction: back( + !isGuidedMode && previousStep === 'qr-scan' + ? 'qr-scan' + : 'method-select', + ), + }; + + case 'confirmation': + return { + ...base, + title: 'Confirm Device', + showFooter: true, + primaryAction: { + label: isPending ? 'Claiming...' : 'Confirm & Continue', + onClick: handleConfirmSingleClaim, + disabled: isPending, + }, + secondaryAction: { + label: 'Cancel', + onClick: () => setStep(previousStep), + variant: 'outline' as const, + }, + }; + + case 'claiming': + return { + ...base, + title: 'Claiming Device...', + showCloseButton: false, + preventBackdropClose: true, + }; + + case 'success': + return { + ...base, + title: 'Success!', + showFooter: true, + primaryAction: { + label: isGuidedMode ? 'Continue Setup' : 'See devices', + onClick: () => { + handleClose(); + if (!isGuidedMode) { + router.push( + userScope === 'personal' + ? '/devices/my-devices' + : '/devices/overview', + ); + } + }, + }, + }; + + // ====================================================== + // FAST MODE ONLY STEPS + // ====================================================== + + case 'cohort-import': + return { + ...base, + title: 'Import Cohort', + showFooter: true, + primaryAction: { + label: isImportingCohort ? 'Verifying...' : 'Import', + onClick: handleVerifyCohort, + disabled: isImportingCohort, + }, + secondaryAction: back('method-select'), + }; + + case 'cohort-confirm': + return { + ...base, + title: 'Confirm Cohort', + showFooter: true, + primaryAction: { + label: 'Confirm & Import', + onClick: handleConfirmCohortImport, + }, + secondaryAction: { + label: 'Cancel', + onClick: () => setStep('cohort-import'), + variant: 'outline' as const, + }, + }; + + case 'assigning-cohort': + return { + ...base, + title: 'Assigning Cohort...', + showCloseButton: false, + preventBackdropClose: true, + }; + + case 'bulk-input': + return { + ...base, + title: 'Add Multiple Devices', + showFooter: true, + primaryAction: { + label: 'Review & Claim', + onClick: handleBulkSubmit, + }, + secondaryAction: back('method-select'), + }; + + case 'bulk-confirmation': + return { + ...base, + title: 'Confirm Bulk Claim', + showFooter: true, + primaryAction: { + label: isBulkPending ? 'Claiming...' : 'Confirm & Claim', + onClick: handleConfirmBulkClaim, + disabled: isBulkPending, + }, + secondaryAction: { + label: 'Cancel', + onClick: () => setStep('bulk-input'), + variant: 'outline' as const, + }, + }; + + case 'bulk-claiming': + return { + ...base, + title: 'Claiming Devices...', + showCloseButton: false, + preventBackdropClose: true, + }; + + case 'bulk-results': { + const hasSuccess = + (bulkClaimData?.data?.successful_claims?.length ?? 0) > 0; + + return { + ...base, + title: 'Bulk Claim Results', + showFooter: true, + primaryAction: { + label: hasSuccess ? 'Go to Devices' : 'Close', + onClick: () => { + handleClose(); + if (hasSuccess) { + router.push( + userScope === 'personal' + ? '/devices/my-devices' + : '/devices/overview', + ); + } + }, + }, + }; + } + + default: + return base; + } + }; + + const dialogConfig = getDialogConfig(); + + // ========================================================== + // Render + // ========================================================== + + return ( + +
+ {step === 'method-select' && ( + // Pass mode so MethodSelectStep can hide bulk/cohort options + // in guided mode (only manual + QR should appear). + + )} + + {step === 'qr-scan' && ( + { + setPreviousStep('qr-scan'); + setStep('manual-input'); + }} + onError={() => { + setPreviousStep('qr-scan'); + setStep('manual-input'); + setError( + 'QR scanner encountered an issue. Please enter details manually.', + ); + }} + /> + )} + + {step === 'manual-input' && ( + + )} + + {step === 'confirmation' && pendingSingleClaim && } + + {step === 'claiming' && ( + + )} + + {step === 'success' && + (claimData?.device || isCohortAssignmentSuccess) && ( + + )} + + <> + {step === 'bulk-input' && ( + + )} + + {step === 'cohort-import' && ( + { + setCohortIdInput(val); + setError(null); + }} + error={error} + isImporting={isImportingCohort} + /> + )} + + {step === 'cohort-confirm' && verifiedCohort && ( + + )} + + {step === 'bulk-confirmation' && ( + d.device_name.trim() && d.claim_token.trim(), + ).length + } + /> + )} + + {step === 'bulk-claiming' && ( + + )} + + {step === 'assigning-cohort' && ( + + )} + + {step === 'bulk-results' && bulkClaimData?.data && ( + + )} + + +
+
+ ); +}; + +export default ClaimDeviceModal; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx b/src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx new file mode 100644 index 0000000000..cb757c8e42 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx @@ -0,0 +1,128 @@ +'use client'; + +import React from 'react'; +import { CheckCircle2, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'; +import { BulkDeviceClaimResponse } from '@/app/types/devices'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; + +interface BulkClaimResultsProps { + results: BulkDeviceClaimResponse['data']; +} + +export const BulkClaimResults: React.FC = ({ results }) => { + const [showSuccessful, setShowSuccessful] = React.useState(false); + const [showFailed, setShowFailed] = React.useState(false); + + const { successful_claims, failed_claims } = results; + + const successful_count = successful_claims.length; + const failed_count = failed_claims.length; + const total_requested = successful_count + failed_count; + + return ( +
+ {/* Summary Statistics */} +
+
+
Total Requested
+
{total_requested}
+
+
+
Successful
+
{successful_count}
+
+
+
Failed
+
{failed_count}
+
+
+ + {/* Successful Claims */} + {successful_claims.length > 0 && ( + + +
+
+ + + Successfully Claimed Devices ({successful_claims.length}) + +
+ {showSuccessful ? ( + + ) : ( + + )} +
+
+ +
+ {successful_claims.map((result, index) => ( +
+
+ +
+

+ {result.device?.long_name || result.device_name} +

+

+ {result.device?.name || result.device_name} +

+
+
+ Claimed +
+ ))} +
+
+
+ )} + + {/* Failed Claims */} + {failed_claims.length > 0 && ( + + +
+
+ + + Failed Claims ({failed_claims.length}) + +
+ {showFailed ? ( + + ) : ( + + )} +
+
+ +
+ {failed_claims.map((result, index) => ( +
+
+ +
+

+ {result.device_name} +

+

+ {result.error || 'Unknown error occurred'} +

+
+
+
+ ))} +
+
+
+ )} +
+ ); +}; diff --git a/src/vertex-template/components/features/claim/steps/BulkInputStep.tsx b/src/vertex-template/components/features/claim/steps/BulkInputStep.tsx new file mode 100644 index 0000000000..5bac6b7cb2 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/BulkInputStep.tsx @@ -0,0 +1,97 @@ +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { BulkDevice, ErrorAlert } from "../claim-device-modal"; +import { FileUploadParser } from "../FileUploadParser"; +import { Plus } from "lucide-react"; +import { DeviceEntryRow } from "../DeviceEntryRow"; +import CohortAssignmentBanner from "./CohortAssignmentBanner"; + +const BulkInputStep = ({ + bulkDevices, + isPersonalContext, + isExternalOrg, + defaultCohort, + activeGroupTitle, + error, + onAddDevice, + onRemoveDevice, + onDeviceChange, + onFileImport, + onClear, +}: { + bulkDevices: BulkDevice[]; + isPersonalContext: boolean; + isExternalOrg: boolean; + defaultCohort: string | null; + activeGroupTitle?: string; + error: string | null; + onAddDevice: () => void; + onRemoveDevice: (i: number) => void; + onDeviceChange: (i: number, field: 'device_name' | 'claim_token', val: string) => void; + onFileImport: (devices: BulkDevice[]) => void; + onClear: () => void; +}) => { + if (bulkDevices.length === 0) { + return ( +
+
+ +
+
+
+ +
+
+ Or +
+
+ +
+ ); + } + + const validCount = bulkDevices.filter((d) => d.device_name.trim() && d.claim_token.trim()).length; + + return ( + <> + {!isPersonalContext && defaultCohort && ( + + )} +
+

Review your devices before claiming.

+ +
+
+ {bulkDevices.map((device, index) => ( + onDeviceChange(index, 'device_name', val)} + onClaimTokenChange={(val) => onDeviceChange(index, 'claim_token', val)} + onRemove={() => onRemoveDevice(index)} + showRemove + /> + ))} +
+
+ Add Row +
+ onFileImport([...bulkDevices, ...devices])} /> +
+
+ {error && } +
+ {validCount} device(s) ready to claim +
+ + ); +}; + +export default BulkInputStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx b/src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx new file mode 100644 index 0000000000..0c024fbd09 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx @@ -0,0 +1,22 @@ +const CohortAssignmentBanner = ({ + isExternalOrg, + isPersonalContext, + activeGroupTitle, +}: { + isExternalOrg: boolean; + isPersonalContext: boolean; + activeGroupTitle?: string; +}) => ( +
+

+ Device will be added to: + {isExternalOrg && activeGroupTitle && ` ${activeGroupTitle}`} + {isPersonalContext && ` as your personal devices`} +

+

+ You can change ownership or share devices later. +

+
+); + +export default CohortAssignmentBanner; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/CohortImportStep.tsx b/src/vertex-template/components/features/claim/steps/CohortImportStep.tsx new file mode 100644 index 0000000000..37dec07cc5 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/CohortImportStep.tsx @@ -0,0 +1,33 @@ +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import { Loader2 } from "lucide-react"; + +const CohortImportStep = ({ + cohortIdInput, + onChange, + error, + isImporting, +}: { + cohortIdInput: string; + onChange: (val: string) => void; + error: string | null; + isImporting: boolean; +}) => ( +
+

Enter the Cohort ID to automatically load its devices.

+ onChange(e.target.value)} + error={error || undefined} + /> + {isImporting && ( +
+ + Verifying Cohort ID... +
+ )} +
+); + +export default CohortImportStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx b/src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx new file mode 100644 index 0000000000..3f89e966b8 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx @@ -0,0 +1,79 @@ +import { AlertCircle } from "lucide-react"; +import { VerifiedCohort } from "../claim-device-modal"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + + +const DeploymentWarning = ({ isBulk = false }: { isBulk?: boolean }) => ( +

+ + Warning: {isBulk ? 'Any devices currently' : 'If this device is currently'}{' '} + + + + + +

Deployment triggers data transmission for a device

+ + + {isBulk ? ' will be automatically' : ', it will be automatically'}{' '} + + + + + +

Recalling removes a device from a Site (e.g., for repair) without deleting it from your inventory.

+
+
+ {isBulk ? ' and added to your inventory.' : '.'} + +

+); + +export const CohortConfirmStep = ({ + verifiedCohort, + isExternalOrg, +}: { + verifiedCohort: VerifiedCohort; + isExternalOrg: boolean; +}) => ( +
+
+

+ Cohort Name: {verifiedCohort.name} +

+

+ This will add the devices from this cohort to your {isExternalOrg ? 'organization' : 'personal'} assets. +

+
+

Continue to import this cohort?

+
+); + +export const BulkConfirmationStep = ({ count }: { count: number }) => ( +
+
+ +
+
+

Confirm Bulk Claim

+
+ You are about to claim {count} devices. +
+ +
+
+); + +export const ConfirmationStep = () => ( +
+
+ +
+
+

Confirm Device Claim

+

Are you sure you want to claim this device?

+ +
+
+); \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/ManualInputStep.tsx b/src/vertex-template/components/features/claim/steps/ManualInputStep.tsx new file mode 100644 index 0000000000..4b898f18b3 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/ManualInputStep.tsx @@ -0,0 +1,53 @@ +import { useForm } from 'react-hook-form'; +import ReusableInputField from '@/components/shared/inputfield/ReusableInputField'; +import { Form, FormField } from '@/components/ui/form'; +import { ClaimDeviceFormData, ErrorAlert } from '../claim-device-modal'; +import CohortAssignmentBanner from './CohortAssignmentBanner'; + +const ManualInputStep = ({ + formMethods, + isPersonalContext, + isExternalOrg, + defaultCohort, + activeGroupTitle, + error, +}: { + formMethods: ReturnType>; + isPersonalContext: boolean; + isExternalOrg: boolean; + defaultCohort: string | null; + activeGroupTitle?: string; + error: string | null; +}) => ( +
+
+ {!isPersonalContext && defaultCohort && ( + + )} +

Enter the device details found on the shipping label.

+
+ ( + + )} + /> + ( + + )} + /> +
+ {error && } +
+
+); + +export default ManualInputStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx b/src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx new file mode 100644 index 0000000000..c6bf594840 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx @@ -0,0 +1,89 @@ +import { + // Smartphone, + // FileSpreadsheet, + Database } from 'lucide-react'; +import { ClaimFlowMode, FlowStep } from '../claim-device-modal'; + +interface MethodSelectStepProps { + onSelect: (step: FlowStep) => void; + mode?: ClaimFlowMode; +} + +const ALL_METHODS = [ + { + step: 'cohort-import' as FlowStep, + icon: ( + + ), + iconBg: + 'bg-violet-100 dark:bg-violet-900/40 group-hover:bg-violet-200 dark:group-hover:bg-violet-800', + border: + 'hover:border-violet-500 dark:hover:border-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20', + title: 'Import from Cohort', + desc: 'Enter a Cohort ID to prefill devices.', + modes: ['guided', 'fast'] as ClaimFlowMode[], + }, + /* + { + step: 'qr-scan' as FlowStep, + icon: , + iconBg: + 'bg-blue-100 dark:bg-blue-900/40 group-hover:bg-blue-200 dark:group-hover:bg-blue-800', + border: + 'hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20', + title: 'Add Single Device', + desc: 'Scan a QR code or manually enter a Device ID.', + modes: ['guided', 'fast'] as ClaimFlowMode[], + }, + { + step: 'bulk-input' as FlowStep, + icon: ( + + ), + iconBg: + 'bg-green-100 dark:bg-green-900/40 group-hover:bg-green-200 dark:group-hover:bg-green-800', + border: + 'hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20', + title: 'Add Multiple Devices', + desc: 'Upload a CSV file or enter a list of IDs for bulk setup.', + modes: ['guided', 'fast'] as ClaimFlowMode[], + }, + */ +]; + +const MethodSelectStep = ({ onSelect, mode = 'fast' }: MethodSelectStepProps) => { + const visibleMethods = ALL_METHODS.filter(m => m.modes.includes(mode)); + + return ( +
+

+ Choose how you would like to add your device(s). +

+
+ {visibleMethods.map(({ step, icon, iconBg, border, title, desc }) => ( + + ))} +
+
+ ); +}; + +export default MethodSelectStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/QRScanStep.tsx b/src/vertex-template/components/features/claim/steps/QRScanStep.tsx new file mode 100644 index 0000000000..700f0eae0a --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/QRScanStep.tsx @@ -0,0 +1,98 @@ +import { Component, ReactNode } from "react"; +import { QRScanner } from "../../devices/qr-scanner"; +import CohortAssignmentBanner from "./CohortAssignmentBanner"; +import logger from "@/lib/logger"; + +// ============================================================ +// ERROR BOUNDARY +// ============================================================ + +interface QRScannerErrorBoundaryProps { + children: ReactNode; + isOpen?: boolean; + fallback?: ReactNode; + onError?: () => void; +} + +interface QRScannerErrorBoundaryState { + hasError: boolean; +} + +class QRScannerErrorBoundary extends Component { + constructor(props: QRScannerErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + componentDidUpdate(prevProps: QRScannerErrorBoundaryProps) { + if (!prevProps.isOpen && this.props.isOpen && this.state.hasError) { + this.setState({ hasError: false }); + } + } + + static getDerivedStateFromError(): QRScannerErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error) { + if (process.env.NODE_ENV === 'development') { + logger.warn('QR Scanner error caught by boundary:', error); + } + this.props.onError?.(); + } + + render(): ReactNode { + if (this.state.hasError) return this.props.fallback || null; + return this.props.children; + } +} + +const QRScanStep = ({ + isOpen, + isPersonalContext, + isExternalOrg, + defaultCohort, + activeGroupTitle, + onScan, + onManualEntry, + onError, +}: { + isOpen: boolean; + isPersonalContext: boolean; + isExternalOrg: boolean; + defaultCohort: string | null; + activeGroupTitle?: string; + onScan: (result: string) => void; + onManualEntry: () => void; + onError: () => void; +}) => ( +
+ {!isPersonalContext && defaultCohort && ( + + )} + + Scanner unavailable. Enter details manually + + } + > + {isOpen && } + + +
+); + +export default QRScanStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/SuccessStep.tsx b/src/vertex-template/components/features/claim/steps/SuccessStep.tsx new file mode 100644 index 0000000000..01d027b901 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/SuccessStep.tsx @@ -0,0 +1,40 @@ +import { CheckCircle2 } from "lucide-react"; + +const SuccessStep = ({ + isCohortAssignmentSuccess, + claimData, +}: { + isCohortAssignmentSuccess: boolean; + claimData?: { device?: { name: string; long_name?: string } }; +}) => ( +
+
+ +
+
+

+ {isCohortAssignmentSuccess ? 'Cohort Assigned Successfully!' : 'Device Claimed Successfully!'} +

+
+ {claimData?.device && ( +
+
+ Device Name: + {claimData.device.long_name || claimData.device.name} +
+
+ Device ID: + {claimData.device.name} +
+
+ Status: + + Claimed + +
+
+ )} +
+); + +export default SuccessStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/utils.ts b/src/vertex-template/components/features/claim/utils.ts new file mode 100644 index 0000000000..2c6ace582d --- /dev/null +++ b/src/vertex-template/components/features/claim/utils.ts @@ -0,0 +1,31 @@ +export function parseQRCode( + qrData: string +): { deviceId: string; claimToken: string } | null { + try { + const url = new URL(qrData); + const deviceId = url.searchParams.get('id'); + const claimToken = url.searchParams.get('token'); + if (deviceId && claimToken) return { deviceId, claimToken }; + } catch { + /* not a URL */ + } + + try { + const parsed = JSON.parse(qrData); + if ( + typeof parsed?.device_id === "string" && + parsed.device_id.trim() && + typeof parsed?.token === "string" && + parsed.token.trim() + ) { + return { + deviceId: parsed.device_id, + claimToken: parsed.token, + }; + } + } catch { + /* not JSON */ + } + + return null; +} \ No newline at end of file diff --git a/src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx b/src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx new file mode 100644 index 0000000000..e8b825d68e --- /dev/null +++ b/src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useCohorts, useAssignDevicesToCohort, useGroupCohorts } from "@/core/hooks/useCohorts"; +import { useDevices } from "@/core/hooks/useDevices"; +import { ComboBox } from "@/components/ui/combobox"; +import { AqPlus } from "@airqo/icons-react"; +import { MultiSelectCombobox, Option } from "@/components/ui/multi-select"; +import { CreateCohortDialog, PreselectedDevice } from "./create-cohort"; +import { Cohort } from "@/app/types/cohorts"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { Device } from "@/app/types/devices"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; + +interface AssignCohortDevicesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDevices?: Device[]; + onSuccess?: () => void; + cohortId?: string; + title?: string; +} + +const formSchema = z.object({ + cohortId: z.string().min(1, { + message: "Please select a cohort.", + }), + devices: z.array(z.string()).min(1, { + message: "Please select at least one device.", + }), +}); + +export function AssignCohortDevicesDialog({ + open, + onOpenChange, + selectedDevices, + onSuccess, + cohortId, + title = "Add devices to cohort", +}: AssignCohortDevicesDialogProps) { + const { isExternalOrg, activeGroup } = useUserContext(); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const [cohortSearch, setCohortSearch] = useState(""); + const [debouncedCohortSearch, setDebouncedCohortSearch] = useState(""); + const [deviceSearch, setDeviceSearch] = useState(""); + const [debouncedDeviceSearch, setDebouncedDeviceSearch] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedCohortSearch(cohortSearch); + }, 300); + + return () => clearTimeout(timer); + }, [cohortSearch]); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedDeviceSearch(deviceSearch); + }, 300); + + return () => clearTimeout(timer); + }, [deviceSearch]); + + const { cohorts: allCohorts, isFetching: isFetchingAllCohorts } = useCohorts({ + enabled: open && !isExternalOrg, + search: debouncedCohortSearch, + limit: 100 + }); + + const { data: groupCohortIds, isFetching: isFetchingCohortIds } = useGroupCohorts( + activeGroup?._id, + { enabled: open && isExternalOrg && !!activeGroup?._id } + ); + + const { cohorts: searchedCohorts, isFetching: isFetchingGroupCohorts } = useCohorts({ + enabled: open && isExternalOrg && !!activeGroup?._id, + search: debouncedCohortSearch, + limit: 100 + }); + + const filteredGroupCohorts = useMemo(() => { + if (!isExternalOrg || !groupCohortIds || groupCohortIds.length === 0) { + return searchedCohorts; + } + const cohortIdSet = new Set(groupCohortIds); + return searchedCohorts.filter(cohort => cohortIdSet.has(cohort._id)); + }, [isExternalOrg, searchedCohorts, groupCohortIds]); + + const cohorts = isExternalOrg ? filteredGroupCohorts : allCohorts; + const isFetchingCohorts = isExternalOrg ? (isFetchingGroupCohorts || isFetchingCohortIds) : isFetchingAllCohorts; + + const { devices: allDevices, isFetching: isFetchingDevices } = useDevices({ + enabled: open, + search: debouncedDeviceSearch, + }); + const { mutate: assignDevices, isPending: isAssigning } = useAssignDevicesToCohort({ + onSuccess: (variables) => { + showBannerWithDelay({ + severity: 'success', + message: `${variables.deviceIds.length} device(s) assigned to cohort successfully`, + scoped: false, + }); + }, + onError: (error) => { + showBanner({ + severity: 'error', + message: `Failed to assign devices: ${getApiErrorMessage(error)}`, + scoped: true, + }); + }, + }); + + const [createCohortModalOpen, setCreateCohortModalOpen] = useState(false); + const [preselectedForCreate, setPreselectedForCreate] = useState([]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + cohortId: cohortId || "", + devices: selectedDevices?.map((d) => d._id).filter((id): id is string => !!id) || [], + }, + }); + + const deviceOptions: Option[] = useMemo(() => { + const combined = [...(selectedDevices ?? []), ...(allDevices ?? [])]; + const unique = new Map(); + + combined.forEach((device) => { + if (!device?._id) return; + unique.set(device._id, { + value: device._id, + label: device.long_name || device.name || `Device ${device._id}`, + }); + }); + + return Array.from(unique.values()); + }, [allDevices, selectedDevices]); + + useEffect(() => { + if (open) { + form.reset({ + cohortId: cohortId || "", + devices: selectedDevices?.map((d) => d._id).filter((id): id is string => !!id) || [], + }); + setCohortSearch(""); + setDebouncedCohortSearch(""); + setDeviceSearch(""); + setDebouncedDeviceSearch(""); + } + }, [open, form, cohortId, selectedDevices]); + + const handleCreateCohortSuccess = () => { + setCreateCohortModalOpen(false); + onOpenChange(false); + }; + + const handleCreateCohortClose = (open: boolean) => { + if (!open) { + setCreateCohortModalOpen(false); + } + }; + + const handleCreateCohortAction = () => { + const selectedIds = form.getValues("devices") || []; + const preselected = deviceOptions + .filter((opt) => selectedIds.includes(opt.value)) + .map((opt) => ({ value: opt.value, label: opt.label })); + + setPreselectedForCreate(preselected); // store in state + onOpenChange(false); + setCreateCohortModalOpen(true); + }; + + + function onSubmit(values: z.infer) { + assignDevices( + { + cohortId: values.cohortId, + deviceIds: values.devices, + }, + { + onSuccess: () => { + onOpenChange(false); + form.reset(); + onSuccess?.(); + }, + } + ); + } + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen); + if (!newOpen) { + form.reset(); + setCreateCohortModalOpen(false); + } + }; + + return ( + <> + handleOpenChange(false)} + title={title} + subtitle={`${form.watch("devices")?.length || 0} device(s) selected`} + size="lg" + maxHeight="max-h-[70vh]" + primaryAction={{ + label: "Add", + onClick: form.handleSubmit(onSubmit), + disabled: !form.watch("cohortId") || !form.watch("devices")?.length || isAssigning, + }} + secondaryAction={{ + label: "Cancel", + onClick: () => onOpenChange(false), + variant: "outline", + disabled: isAssigning, + }} + > +
+ + ( + + + Cohort * + + + ({ + value: cohort._id, + label: cohort.name, + }))} + value={field.value} + onValueChange={field.onChange} + placeholder="Select a cohort" + searchPlaceholder="Search cohorts..." + emptyMessage="No cohorts found" + disabled={!!cohortId} + className="w-full" + allowCustomInput={false} + customActionLabel="Create New Cohort" + customActionIcon={AqPlus} + onCustomAction={handleCreateCohortAction} + onSearchChange={setCohortSearch} + isLoading={isFetchingCohorts} + /> + + + + )} + /> + + ( + + + Devices * + + + + + {isFetchingDevices && ( +

Searching devices...

+ )} + +
+ )} + /> + + +
+ + + + + ); +} diff --git a/src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx b/src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx new file mode 100644 index 0000000000..247a158e5f --- /dev/null +++ b/src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { useAssignCohortsToGroup, useCohorts } from "@/core/hooks/useCohorts"; +import { useGroups } from "@/core/hooks/useGroups"; +import { ComboBox } from "@/components/ui/combobox"; +import { MultiSelectCombobox, Option } from "@/components/ui/multi-select"; +import { Cohort } from "@/app/types/cohorts"; +import { Group } from "@/app/types/groups"; + +interface AssignCohortsToGroupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialSelectedCohortIds: string[]; + onSuccess?: () => void; +} + +export function AssignCohortsToGroupDialog({ + open, + onOpenChange, + initialSelectedCohortIds, + onSuccess, +}: AssignCohortsToGroupDialogProps) { + const [selectedGroupId, setSelectedGroupId] = useState(""); + const [selectedCohortIds, setSelectedCohortIds] = useState(initialSelectedCohortIds); + const [errors, setErrors] = useState<{ group?: string; cohorts?: string }>({}); + + const { cohorts: allCohorts } = useCohorts(); + const { groups: allGroups, isLoading: isLoadingGroups } = useGroups(); + const { mutate: assignToGroup, isPending } = useAssignCohortsToGroup(); + + useEffect(() => { + if (open) { + setSelectedCohortIds(initialSelectedCohortIds); + } + }, [open, initialSelectedCohortIds]); + + const cohortOptions: Option[] = useMemo(() => { + return (allCohorts || []).map((cohort: Cohort) => ({ + value: cohort._id, + label: cohort.name, + })); + }, [allCohorts]); + + const groupOptions = useMemo(() => { + return (allGroups || []).map((group: Group) => ({ + value: group._id, + label: group.grp_title, + })); + }, [allGroups]); + + const handleClose = () => { + onOpenChange(false); + // Reset state after dialog closes + setTimeout(() => { + setSelectedGroupId(""); + setSelectedCohortIds([]); + setErrors({}); + }, 150); // Delay to allow for closing animation + }; + + const handleSubmit = () => { + const newErrors: { group?: string; cohorts?: string } = {}; + if (!selectedGroupId) { + newErrors.group = "Please select an organization."; + } + if (selectedCohortIds.length === 0) { + newErrors.cohorts = "Please select at least one cohort."; + } + + setErrors(newErrors); + + if (Object.keys(newErrors).length > 0) { + return; + } + + assignToGroup( + { + groupId: selectedGroupId, + cohortIds: selectedCohortIds, + }, + { + onSuccess: () => { + onSuccess?.(); + handleClose(); + }, + } + ); + }; + + return ( + +
+
+ + { + setSelectedGroupId(value); + if (errors.group) setErrors(e => ({...e, group: undefined})); + }} + placeholder="Select an organization" + searchPlaceholder="Search organization..." + emptyMessage={isLoadingGroups ? "Loading organization..." : "No organization found."} + disabled={isPending || isLoadingGroups} + allowCustomInput={false} + /> + {errors.group &&

{errors.group}

} +
+
+ + { + setSelectedCohortIds(values); + if (errors.cohorts) setErrors(e => ({...e, cohorts: undefined})); + }} + placeholder="Select cohorts..." + allowCreate={false} + /> + {errors.cohorts &&

{errors.cohorts}

} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/cohorts/cohort-detail-card.tsx b/src/vertex-template/components/features/cohorts/cohort-detail-card.tsx new file mode 100644 index 0000000000..76a4d0ca87 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/cohort-detail-card.tsx @@ -0,0 +1,319 @@ +import { Card } from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { AqCopy01, AqEdit01 } from "@airqo/icons-react"; +import { Switch } from "@/components/ui/switch"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; +import { useEffect, useState } from "react"; +import { useUpdateCohortDetails, useOriginalCohort } from "@/core/hooks/useCohorts"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { Badge } from "@/components/ui/badge"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { InfoBanner } from "@/components/ui/banner"; + +interface CohortDetailsCardProps { + name: string; + id: string; + visibility: boolean; + onShowDetailsModal: () => void; + loading: boolean; + cohort_tags?: string[]; +} + +const CohortDetailsCard: React.FC = ({ + name, + id, + visibility, + onShowDetailsModal, + loading, + cohort_tags, +}) => { + const [isVisibilityDialogOpen, setIsVisibilityDialogOpen] = useState(false); + const [isTagsDialogOpen, setIsTagsDialogOpen] = useState(false); + const [targetVisibility, setTargetVisibility] = useState(false); + const [selectedTags, setSelectedTags] = useState([]); + const pathname = usePathname(); + const isAdminPath = pathname.startsWith("/admin"); + const isDuplicate = (cohort_tags ?? []).some( + (tag) => tag.toLowerCase() === "duplicate" + ); + + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const { mutateAsync: updateCohort, isPending } = useUpdateCohortDetails(); + const { data: originalData } = useOriginalCohort(id, { enabled: !!isDuplicate }); + const originalCohort = isDuplicate && originalData?.success ? originalData.original_cohort : null; + + useEffect(() => { + setSelectedTags(cohort_tags ?? []); + }, [cohort_tags]); + + const handleToggleVisibility = (checked: boolean) => { + setTargetVisibility(checked); + setIsVisibilityDialogOpen(true); + }; + + const handleConfirmVisibilityUpdate = async () => { + try { + await updateCohort({ cohortId: id, data: { visibility: targetVisibility } }); + setIsVisibilityDialogOpen(false); + showBannerWithDelay({ + severity: 'success', + message: `Cohort is now ${targetVisibility ? 'public' : 'private'}`, + scoped: false, + }); + } catch (error) { + showBanner({ + severity: 'error', + message: `Failed to update visibility: ${getApiErrorMessage(error)}`, + scoped: true, + }); + } + }; + + const handleConfirmTagsUpdate = async () => { + try { + await updateCohort({ cohortId: id, data: { cohort_tags: selectedTags } }); + setIsTagsDialogOpen(false); + showBannerWithDelay({ + severity: 'success', + message: 'Cohort tags updated successfully', + scoped: false, + }); + } catch (error) { + showBanner({ + severity: 'error', + message: `Failed to update tags: ${getApiErrorMessage(error)}`, + scoped: true, + }); + } + }; + + if (loading) { + return ( + + + + ); + } + + return ( + <> + +
+

Cohort Details

+ +
+
+
+ Cohort Name +
+
+
+ {name || "-"} +
+ +
+
+ +
+
+
+ Tags +
+ setIsTagsDialogOpen(true)} + className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full h-fit w-fit" + Icon={AqEdit01} + aria-label="Edit tags" + /> +
+
+ {cohort_tags && cohort_tags.length > 0 ? ( + cohort_tags.map((tag) => ( + + {tag} + + )) + ) : ( + + None + + )} +
+
+ +
+
+ Visibility +
+
+ + Private + + + + Public + +
+
+ +
+
+ Cohort ID +
+
+
+ {id || "-"} +
+ { + if (!id) return; + try { + await navigator.clipboard.writeText(id); + showBanner({ severity: 'success', message: 'Cohort ID copied to clipboard', scoped: false }); + } catch { + showBanner({ severity: 'error', message: 'Failed to copy to clipboard', scoped: false }); + } + }} + className="p-1" + Icon={AqCopy01} + /> +
+
+
+ + {originalCohort && ( + + This cohort is a duplicate. The original cohort is{" "} + + {originalCohort.name} + + + } + /> + )} +
+
+ + !isPending && setIsVisibilityDialogOpen(false)} + title={ + targetVisibility ? "Make cohort public?" : "Make cohort private?" + } + size="md" + customFooter={ +
+ setIsVisibilityDialogOpen(false)} + disabled={isPending} + > + Cancel + + + {isPending + ? "Updating..." + : targetVisibility + ? "Confirm & Publish" + : "Confirm & Make Private"} + +
+ } + > +
+

+ {targetVisibility + ? "You are about to make this cohort visible on the public AirQo Map. This means anyone can see readings from devices in this cohort." + : "You are about to make this cohort private. Data from devices in this cohort will only be visible to your organization and will not appear on the public map."} +

+
+
+ + !isPending && setIsTagsDialogOpen(false)} + title="Edit Cohort Tags" + size="md" + customFooter={ +
+ setIsTagsDialogOpen(false)} + disabled={isPending} + > + Cancel + + + {isPending ? "Updating..." : "Save Changes"} + +
+ } + > +
+ +
+
+ + ); +}; + +export default CohortDetailsCard; diff --git a/src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx b/src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx new file mode 100644 index 0000000000..d158e0e304 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Copy } from "lucide-react"; +import React from "react"; +import { useClipboard } from "@/core/hooks/useClipboard"; + +interface CohortMeasurementsApiCardProps { + cohortId: string; +} + +const CohortMeasurementsApiCard: React.FC = ({ cohortId }) => { + const { handleCopy } = useClipboard({ successMessage: 'API URL copied to clipboard', errorMessage: 'Failed to copy to clipboard' }); + + return ( + +

Cohort Measurements API

+ {/* Recent Measurements */} +
+
Recent Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/recent?token=YOUR_TOKEN`} +
+ +
+
+ {/* Historical Measurements */} +
+
Historical Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/historical?token=YOUR_TOKEN`} +
+ +
+
+
+ ); +}; + +export default CohortMeasurementsApiCard; diff --git a/src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx b/src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx new file mode 100644 index 0000000000..4275b03187 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx @@ -0,0 +1,229 @@ +import { useState, useMemo } from "react"; +import { Card } from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Group } from "@/app/types/groups"; +import { useGroupsByCohort } from "@/core/hooks/useGroups"; +import { UnassignCohortFromGroupDialog } from "./unassign-cohort-from-group"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableTable, { TableColumn, TableItem } from "@/components/shared/table/ReusableTable"; +import ReusableToast from "@/components/shared/toast/ReusableToast"; +import { AqCopy01 } from "@airqo/icons-react"; +import { formatTitle } from "../org-picker/organization-picker"; + +interface CohortOrganizationsCardProps { + cohortId: string; + cohortName: string; + canUnassign?: boolean; +} + +export function CohortOrganizationsCard({ + cohortId, + cohortName, + canUnassign = false, +}: CohortOrganizationsCardProps) { + const { groups: organizations, isLoading, refetch } = useGroupsByCohort(cohortId); + + const [selectedOrg, setSelectedOrg] = useState(null); + const [showUnassignDialog, setShowUnassignDialog] = useState(false); + const [showAllDialog, setShowAllDialog] = useState(false); + + const handleUnassignClick = (org: Group) => { + setSelectedOrg(org); + setShowUnassignDialog(true); + }; + + const handleUnassignSuccess = () => { + setShowUnassignDialog(false); + setSelectedOrg(null); + refetch(); // Refetch the organizations assigned to the cohort + }; + + const handleCopyId = (id: string) => { + navigator.clipboard.writeText(id); + ReusableToast({ message: "Group ID copied to clipboard!", type: "SUCCESS" }); + }; + + const tableData = useMemo(() => { + return organizations.map((org) => ({ ...org, id: org._id })); + }, [organizations]); + + const tableColumns: TableColumn[] = [ + { + key: "grp_title", + label: "Name", + render: (value, item) => {formatTitle(item.grp_title)}, + sortable: true, + }, + { + key: "_id", + label: "Org ID", + render: (value, item) => ( +
+ {item._id} + { + e.stopPropagation(); + handleCopyId(item._id); + }} + Icon={AqCopy01} + aria-label="Copy Group ID" + /> +
+ ), + }, + { + key: "grp_country", + label: "Country", + render: (value, item) => item.grp_country || "-", + sortable: true, + }, + { + key: "actions" as keyof (Group & { id: string }), + label: "Action", + render: (value, item) => ( + + + +
+ handleUnassignClick(item)} + disabled={!canUnassign} + className="data-[state=checked]:bg-red-600" + aria-label="Unassign from cohort" + /> +
+
+ + Remove cohort assignment + +
+
+ ), + }, + ]; + + if (isLoading) { + return ( + + + + ); + } + + const displayOrganizations = organizations.slice(0, 3); + const hasMore = organizations.length > 3; + + return ( + <> + +
+

Assigned Organizations

+ + {organizations.length === 0 ? ( +
+ No organizations assigned to this cohort. +
+ ) : ( +
+ {displayOrganizations.map((org) => ( +
+
+
+

+ {org.grp_title} +

+ +
+
+ Organization ID +
+
+ + {org._id} + + handleCopyId(org._id)} + className="p-1 h-auto text-muted-foreground hover:text-primary" + Icon={AqCopy01} + aria-label="Copy Organization ID" + /> +
+
+
+ +
+ handleUnassignClick(org)} + disabled={!canUnassign} + className="text-xs px-2 py-1 text-red-600 border-red-200 hover:bg-red-500 dark:hover:bg-red-950 dark:border-red-900" + > + Unassign + +
+
+
+ ))} +
+ )} +
+ + {hasMore && ( +
+ setShowAllDialog(true)} + > + View more organizations ({organizations.length - 3}) + +
+ )} +
+ + {/* Dialog for "View More" table */} + setShowAllDialog(false)} + title={`All Assigned Organizations (${organizations.length})`} + size="3xl" + > +
+ []} + searchable={true} + searchableColumns={["grp_title", "grp_country", "_id"]} + filterable={false} + showPagination={true} + pageSize={5} + pageSizeOptions={[5, 10, 20]} + exportable={false} + tableId={`cohort-orgs-${cohortId}`} + /> +
+
+ + {/* Unassign Confirmation Dialog */} + + + ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx b/src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx new file mode 100644 index 0000000000..8f2dd89c45 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx @@ -0,0 +1,45 @@ +'use client'; + +import React, { useState } from 'react'; +import { Plus } from 'lucide-react'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import { AqCollocation } from '@airqo/icons-react'; +import dynamic from 'next/dynamic'; + +const ClaimDeviceModal = dynamic( + () => import('../claim/claim-device-modal'), + { ssr: false } +); + +const CohortsEmptyState = () => { + const [isClaimModalOpen, setIsClaimModalOpen] = useState(false); + + return ( +
+
+ +
+ +

+ No Cohorts Found +

+ +

+ Get started by claiming widespread AirQo devices to automatically create your first device cohort. +

+ +
+ setIsClaimModalOpen(true)} Icon={Plus}> + Add AirQo Device + +
+ + setIsClaimModalOpen(false)} + /> +
+ ); +}; + +export default CohortsEmptyState; diff --git a/src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx b/src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx new file mode 100644 index 0000000000..5fd4a420e0 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import { useCreateCohortFromCohorts } from "@/core/hooks/useCohorts"; +import { useNetworks } from "@/core/hooks/useNetworks"; +import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { Label } from "@/components/ui/label"; +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; +import { buildCohortName, sanitizeCohortInput } from "@/core/utils/cohortName"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +interface CreateCohortFromSelectionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedCohortIds: string[]; + onSuccess?: () => void; + andNavigate?: boolean; +} + +export function CreateCohortFromSelectionDialog({ + open, + onOpenChange, + selectedCohortIds, + onSuccess, + andNavigate = true, +}: CreateCohortFromSelectionDialogProps) { + const [city, setCity] = useState(""); + const [projectName, setProjectName] = useState(""); + const [funder, setFunder] = useState(""); + const [name, setName] = useState(""); + const [network, setNetwork] = useState(""); + const [description, setDescription] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); + const [error, setError] = useState(""); + const [showIgnoredTooltip, setShowIgnoredTooltip] = useState({ + city: false, + projectName: false, + funder: false, + }); + const tooltipTimers = useRef | null>>({}); + const router = useRouter(); + const { mutate: createFromCohorts, isPending } = useCreateCohortFromCohorts(); + const { networks, isLoading: isLoadingNetworks } = useNetworks(); + + useEffect(() => { + if (!open) { + setCity(""); + setProjectName(""); + setFunder(""); + setName(""); + setNetwork(""); + setDescription(""); + setNetwork(""); + setDescription(""); + setSelectedTags([]); + setError(""); + } + }, [open]); + + const handleClose = () => { + onOpenChange(false); + }; + + const handleSubmit = () => { + const isOrganizational = selectedTags.includes("organizational"); + if (isOrganizational) { + if (city.trim().length === 0) { + setError("City is required."); + return; + } + if (projectName.trim().length === 0) { + setError("Project name is required."); + return; + } + } else if (name.trim().length === 0) { + setError("Cohort name is required."); + return; + } + if (!network) { + setError("Please select a Sensor Manufacturer."); + return; + } + if (selectedTags.length === 0) { + setError("Please select at least one tag."); + return; + } + setError(""); + + const derivedName = isOrganizational + ? buildCohortName(city, projectName, funder) + : name.trim(); + if (derivedName.length < 2) { + setError("Cohort name must be at least 2 characters."); + return; + } + + createFromCohorts( + { + name: derivedName, + description: description.trim() || undefined, + cohort_ids: selectedCohortIds, + network, + cohort_tags: selectedTags, + }, + { + onSuccess: (response) => { + onSuccess?.(); + if (andNavigate && response?.data?._id) { + router.push(`/admin/cohorts/${response.data._id}`); + } else { + handleClose(); + } + }, + } + ); + }; + + const handleSanitizedInputChange = ( + fieldKey: "city" | "projectName" | "funder", + value: string, + setter: (nextValue: string) => void + ) => { + const sanitized = sanitizeCohortInput(value); + if (/[^a-zA-Z0-9]/.test(value)) { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: true })); + if (tooltipTimers.current[fieldKey]) { + clearTimeout(tooltipTimers.current[fieldKey] as ReturnType); + } + tooltipTimers.current[fieldKey] = setTimeout(() => { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: false })); + }, 1500); + } + setter(sanitized); + }; + + return ( + +
+
+ + +
+ + {selectedTags.includes("organizational") ? ( + <> +
+ + + +
+ handleSanitizedInputChange("city", e.target.value, setCity)} placeholder="e.g. Nairobi" required /> +
+
+ +

Special character ignored

+
+
+
+ + + +
+ handleSanitizedInputChange("projectName", e.target.value, setProjectName)} placeholder="e.g. WRI" required /> +
+
+ +

Special character ignored

+
+
+
+ + + +
+ handleSanitizedInputChange("funder", e.target.value, setFunder)} placeholder="e.g. EPIC" /> +
+
+ +

Special character ignored

+
+
+
+
+ {error && ( +

{error}

+ )} + + ) : ( + setName(e.target.value)} + placeholder="Enter cohort name" + required + /> + )} + {!selectedTags.includes("organizational") && error && ( +

{error}

+ )} + setNetwork(e.target.value)} + required + placeholder={isLoadingNetworks ? "Loading sensor manufacturer..." : "Select a sensor manufacturer"} + disabled={isLoadingNetworks} + > + {networks.map((network) => ( + + ))} + + + setDescription(e.target.value)} placeholder="Describe this combined cohort" rows={3} /> +
+
+ ); +} diff --git a/src/vertex-template/components/features/cohorts/create-cohort.tsx b/src/vertex-template/components/features/cohorts/create-cohort.tsx new file mode 100644 index 0000000000..e159693d97 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/create-cohort.tsx @@ -0,0 +1,587 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { useDevices } from "@/core/hooks/useDevices"; +import { useCreateCohortWithDevices } from "@/core/hooks/useCohorts"; +import { useNetworks } from "@/core/hooks/useNetworks"; +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput"; +import { DeviceNameParser } from "./device-name-parser"; +import { useBanner } from "@/context/banner-context"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { useAppSelector } from "@/core/redux/hooks"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; +import { buildCohortName, sanitizeCohortInput } from "@/core/utils/cohortName"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +export type PreselectedDevice = { value: string; label: string }; + +const EMPTY_PRESELECTED_DEVICES: PreselectedDevice[] = []; + +interface CreateCohortDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (cohortData?: { cohort: { _id: string; name: string } }) => void; + onError?: (error: unknown) => void; + andNavigate?: boolean; + preselectedDevices?: PreselectedDevice[]; + hideDeviceSelection?: boolean; + preselectedNetwork?: string; +} + +const formSchema = z.object({ + name: z.string().optional(), + city: z.string().optional(), + projectName: z.string().optional(), + funder: z.string().optional(), + network: z.string().min(1, { + message: "Please select a Sensor Manufacturer.", + }), + devices: z.array(z.string()).optional(), + cohort_tags: z.array(z.string()).optional(), +}).superRefine((values, ctx) => { + const isOrganizational = values.cohort_tags?.includes("organizational"); + if (isOrganizational) { + if (!values.city?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "City is required.", path: ["city"] }); + } + if (!values.projectName?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Project name is required.", path: ["projectName"] }); + } + } else { + if (!values.name?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Cohort name is required.", path: ["name"] }); + } + } +}); + +export function CreateCohortDialog({ + open, + onOpenChange, + onSuccess, + onError, + preselectedDevices = EMPTY_PRESELECTED_DEVICES, + hideDeviceSelection = false, + preselectedNetwork, + andNavigate = true, +}: CreateCohortDialogProps) { + const { showBanner } = useBanner(); + const pathname = usePathname(); + const isAdminPage = pathname?.includes('/admin/'); + const { isExternalOrg, activeGroup } = useUserContext(); + const userDetails = useAppSelector((state) => state.user.userDetails); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + city: "", + projectName: "", + funder: "", + network: preselectedNetwork || "", + devices: preselectedDevices.map((d) => d.value), + cohort_tags: [], + }, + }); + + type CohortStep = "form" | "confirmation" | "success"; + const [step, setStep] = useState("form"); + const [createdCohort, setCreatedCohort] = useState<{ _id: string; name: string } | null>(null); + + const selectedNetwork = form.watch("network"); + const [deviceSearch, setDeviceSearch] = useState(""); + const [showIgnoredTooltip, setShowIgnoredTooltip] = useState({ + city: false, + projectName: false, + funder: false, + }); + const tooltipTimers = useRef | null>>({}); + + const { devices, isLoading, error } = useDevices({ + network: selectedNetwork, + search: deviceSearch + }); + + const { networks, isLoading: isLoadingNetworks } = useNetworks(); + + const deviceOptions = useMemo(() => { + return (devices || []).map((d) => ({ + value: d._id || "", + label: d.long_name || d.name || `Device ${d._id}`, + })); + }, [devices]); + + const router = useRouter(); + + const handleSanitizedFieldChange = ( + fieldKey: "city" | "projectName" | "funder", + value: string, + onChange: (nextValue: string) => void + ) => { + const sanitized = sanitizeCohortInput(value); + if (/[^a-zA-Z0-9]/.test(value)) { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: true })); + if (tooltipTimers.current[fieldKey]) { + clearTimeout(tooltipTimers.current[fieldKey] as ReturnType); + } + tooltipTimers.current[fieldKey] = setTimeout(() => { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: false })); + }, 1500); + } + onChange(sanitized); + }; + + useEffect(() => { + if (open) { + form.reset({ + name: "", + city: "", + projectName: "", + funder: "", + network: preselectedNetwork || "", + devices: preselectedDevices.map((d) => d.value), + cohort_tags: [], + }); + setDeviceSearch(""); + setStep("form"); + setCreatedCohort(null); + + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // When embedded in another modal (andNavigate=false + hideDeviceSelection=true), + // skip the success step and close immediately so query invalidations don't + // trigger a re-render cascade in the parent modal. + const isEmbeddedMode = !andNavigate && hideDeviceSelection; + + const { mutate: createCohort, isPending } = useCreateCohortWithDevices({ + invalidateOnSuccess: !isEmbeddedMode, + onSuccess: (response) => { + if (isEmbeddedMode) { + onSuccess?.(response); + onOpenChange(false); + } else if (response?.cohort) { + setCreatedCohort(response.cohort); + setStep("success"); + onSuccess?.(response); + } else { + onSuccess?.(response); + onOpenChange(false); + } + }, + onError: (error) => { + showBanner({ + severity: 'error', + message: `Failed to create cohort: ${getApiErrorMessage(error)}`, + scoped: true, + }); + onError?.(error); + }, + }); + + const handleCancel = () => { + if (step === 'form') { + form.reset(); + setDeviceSearch(""); + onOpenChange(false); + } else if (step === 'success') { + onOpenChange(false); + } else { + setStep('form'); + } + }; + + const handleDeviceImport = (deviceNames: string[]) => { + // Validate that network is selected first (generic block handles this now, but extra safety) + if (!selectedNetwork) return; + + // Convert device names to device IDs by finding matches in deviceOptions + const matchedIds = deviceNames + .map(name => { + const nameLower = name.toLowerCase(); + // Try exact match first + let match = deviceOptions.find(d => d.label.toLowerCase() === nameLower); + + // If no exact match, try contains match + if (!match) { + match = deviceOptions.find(d => + d.label.toLowerCase().includes(nameLower) && nameLower.length >= 3 + ); + } + + return match?.value; + }) + .filter(Boolean) as string[]; + + if (matchedIds.length === 0) { + showBanner({ + severity: 'warning', + message: 'No matching devices found. Please ensure the devices exist in the selected network.', + scoped: true, + }); + return; + } + + // Merge with existing selections + const currentDevices = form.getValues('devices') || []; + const uniqueDevices = Array.from(new Set([...currentDevices, ...matchedIds])); + form.setValue('devices', uniqueDevices); + + const importedCount = matchedIds.length; + const notFoundCount = deviceNames.length - importedCount; + + if (notFoundCount > 0) { + showBanner({ + severity: 'warning', + message: `Imported ${importedCount} device${importedCount !== 1 ? 's' : ''}. ${notFoundCount} not found.`, + scoped: true, + }); + } + }; + + // Intermediate submit handler for form step + const onFormReview = () => { + setStep("confirmation"); + }; + + // Final submit handler + const handleConfirmCreate = () => { + const values = form.getValues(); + const isOrganizational = values.cohort_tags?.includes("organizational"); + const derivedName = isOrganizational + ? buildCohortName(values.city || "", values.projectName || "", values.funder) + : (values.name || "").trim(); + + const payload: Parameters[0] = { + name: derivedName, + network: values.network, + deviceIds: [], + }; + + if (values.devices && values.devices.length > 0) { + payload.deviceIds = values.devices; + } + + if (values.cohort_tags && values.cohort_tags.length > 0) { + payload.cohort_tags = values.cohort_tags; + } + + if (isExternalOrg && activeGroup?._id) { + payload.groupId = activeGroup._id; + } else if (!isExternalOrg && !isAdminPage && userDetails?._id) { + payload.userId = userDetails._id; + } + + createCohort(payload); + }; + + const getDialogConfig = () => { + switch (step) { + case 'confirmation': + return { + title: 'Confirm Cohort Creation', + primaryLabel: isPending ? 'Creating...' : 'Confirm & Create', + primaryAction: handleConfirmCreate, + secondaryLabel: 'Back', + secondaryAction: () => setStep('form'), + showFooter: true + }; + case 'success': + return { + title: 'Success!', + primaryLabel: andNavigate ? 'Go to Cohort Details' : 'Close', + primaryAction: () => { + if (andNavigate && createdCohort?._id) { + router.push(`/admin/cohorts/${createdCohort._id}`); + } else { + onOpenChange(false); + } + }, + secondaryLabel: 'Close', + secondaryAction: () => onOpenChange(false), + showFooter: true + }; + default: // form + return { + title: 'Create Cohort', + primaryLabel: 'Review & Create', + primaryAction: form.handleSubmit(onFormReview), + secondaryLabel: 'Cancel', + secondaryAction: handleCancel, + showFooter: true + }; + } + }; + + const config = getDialogConfig(); + const formValues = form.getValues(); + const isOrganizational = form.watch("cohort_tags")?.includes("organizational"); + const derivedName = isOrganizational + ? buildCohortName(formValues.city || "", formValues.projectName || "", formValues.funder) + : (formValues.name || "").trim(); + + return ( + + {step === 'form' && ( +
+ + {!hideDeviceSelection && ( + ( + + + + + + )} + /> + )} + + {isOrganizational ? ( +
+ ( + + + +
+ handleSanitizedFieldChange("city", e.target.value, field.onChange)} + error={form.formState.errors.city?.message} + /> +
+
+ +

Special character ignored

+
+
+
+ )} + /> + ( + + + +
+ handleSanitizedFieldChange("projectName", e.target.value, field.onChange)} + error={form.formState.errors.projectName?.message} + /> +
+
+ +

Special character ignored

+
+
+
+ )} + /> + ( + + + +
+ handleSanitizedFieldChange("funder", e.target.value, field.onChange)} + /> +
+
+ +

Special character ignored

+
+
+
+ )} + /> +
+ ) : ( + ( + + )} + /> + )} + {(!hideDeviceSelection || !preselectedNetwork) && ( + ( + { + field.onChange(e.target.value); + form.setValue("devices", []); + setDeviceSearch(""); + }} + error={form.formState.errors.network?.message} + required + placeholder={isLoadingNetworks ? "Loading networks..." : "Select a network"} + disabled={isLoadingNetworks} + > + {networks.map((network) => ( + + ))} + + )} + /> + )} + {!hideDeviceSelection && ( + ( + +
+ + +
+ + + + {isLoading &&

Loading devices…

} + {error && ( +

Failed to load devices. Please try again.

+ )} + +
+ )} + /> + )} + + + )} + + {step === 'confirmation' && ( +
+
+ +
+
+

+ Review Cohort Details +

+

+ You are about to create a cohort named {derivedName} in the {formValues.network} network. +

+ {!hideDeviceSelection && ( +
+

+ Devices to be added: +

+

+ {formValues.devices?.length || 0} +

+
+ )} +
+
+ )} + + {step === 'success' && createdCohort && ( +
+
+ +
+
+

+ Cohort Created Successfully! +

+

+ Cohort {createdCohort.name} has been created{hideDeviceSelection ? '.' : ` with ${formValues.devices?.length || 0} devices.`} +

+
+
+ )} +
+ ); +} diff --git a/src/vertex-template/components/features/cohorts/device-name-parser.tsx b/src/vertex-template/components/features/cohorts/device-name-parser.tsx new file mode 100644 index 0000000000..5a094870a8 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/device-name-parser.tsx @@ -0,0 +1,320 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import { FileSpreadsheet } from 'lucide-react'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import { useBanner } from '@/context/banner-context'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export interface ParsedFileData { + headers: string[]; + data: unknown[][]; + fileName: string; +} + +interface DeviceNameColumnMapperProps { + filePreview: ParsedFileData; + onConfirm: (deviceNameColumn: number) => void; + onCancel: () => void; +} + +const DeviceNameColumnMapper: React.FC = ({ + filePreview, + onConfirm, + onCancel, +}) => { + const [deviceNameColumn, setDeviceNameColumn] = useState(() => { + // Auto-detect device name column + const index = filePreview.headers.findIndex(h => + /device.*name|name|device.*id|device|long.*name|device_name/i.test(h.toLowerCase()) + ); + return index !== -1 ? index : 0; + }); + + return ( +
+
+
+

+ Map Device Name Column +

+

+ File: {filePreview.fileName} +

+
+ +
+

+ Please select which column contains the device names: +

+ + {/* Column Selector */} +
+ + +
+ + {/* Preview Table */} +
+ + + + {filePreview.headers.map((header, index) => ( + + ))} + + + + {filePreview.data.slice(1, 6).map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {header} + {index === deviceNameColumn && ( + (Device Name) + )} +
+ {String(cell || '')} +
+
+

+ Showing first 5 rows as preview +

+
+ +
+ + Cancel + + onConfirm(deviceNameColumn)}> + Import Devices + +
+
+
+ ); +}; + +interface DeviceNameCohortParserProps { + onDevicesParsed: (deviceNames: string[]) => void; + shouldBlock?: boolean; + tooltipMessage?: string; + onBlock?: () => void; +} + +export const DeviceNameParser: React.FC = ({ + onDevicesParsed, + shouldBlock = false, + tooltipMessage, + onBlock +}) => { + const [isImporting, setIsImporting] = useState(false); + const [filePreview, setFilePreview] = useState(null); + const fileInputRef = useRef(null); + const { showBanner } = useBanner(); + + const handleLinkClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (shouldBlock) { + onBlock?.(); + return; + } + + if (!isImporting) { + fileInputRef.current?.click(); + } + }; + + const handleFileImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const fileExtension = file.name.split('.').pop()?.toLowerCase(); + if (!['csv', 'xlsx', 'xls'].includes(fileExtension || '')) { + showBanner({ severity: 'error', message: 'Invalid file format. Please upload a CSV or Excel file.', scoped: true }); + e.target.value = ''; + return; + } + + if (file.size > 5 * 1024 * 1024) { + showBanner({ severity: 'error', message: 'File too large. Maximum size is 5MB.', scoped: true }); + e.target.value = ''; + return; + } + + setIsImporting(true); + + try { + let parsedData: unknown[][] = []; + let headers: string[] = []; + + if (fileExtension === 'csv') { + const Papa = (await import('papaparse')).default; + Papa.parse(file, { + complete: (results) => { + parsedData = results.data as unknown[][]; + if (parsedData.length > 0) { + const firstRow = parsedData[0]; + headers = (firstRow as unknown[]).map((cell: unknown, index: number) => { + const cellStr = String(cell || '').trim(); + return cellStr || `Column ${index + 1}`; + }); + setFilePreview({ headers, data: parsedData, fileName: file.name }); + } else { + showBanner({ severity: 'warning', message: 'The file appears to be empty.', scoped: true }); + } + setIsImporting(false); + }, + error: (error) => { + showBanner({ severity: 'error', message: `Error parsing CSV: ${error.message}`, scoped: true }); + setIsImporting(false); + }, + }); + } else { + const XLSX = await import('xlsx'); + const reader = new FileReader(); + reader.onload = (evt) => { + const bstr = evt.target?.result; + const workbook = XLSX.read(bstr, { type: 'binary' }); + const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; + parsedData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }) as unknown[][]; + if (parsedData.length > 0) { + const firstRow = parsedData[0]; + headers = (firstRow as unknown[]).map((cell: unknown, index: number) => { + const cellStr = String(cell || '').trim(); + return cellStr || `Column ${index + 1}`; + }); + setFilePreview({ headers, data: parsedData, fileName: file.name }); + } else { + showBanner({ severity: 'warning', message: 'The file appears to be empty.', scoped: true }); + } + setIsImporting(false); + }; + reader.onerror = () => { + showBanner({ severity: 'error', message: 'Error reading Excel file.', scoped: true }); + setIsImporting(false); + }; + reader.readAsBinaryString(file); + } + } catch { + showBanner({ severity: 'error', message: 'Error importing file. Please try again.', scoped: true }); + setIsImporting(false); + } + + e.target.value = ''; + }; + + const handleConfirmImport = (deviceNameColumn: number) => { + if (!filePreview) return; + + const deviceNames = filePreview.data + .slice(1) + .map((row) => { + const deviceName = row[deviceNameColumn]; + return typeof deviceName === 'string' + ? deviceName.trim() + : String(deviceName || '').trim(); + }) + .filter((name) => name.length > 0); + + if (deviceNames.length === 0) { + showBanner({ severity: 'error', message: 'No valid device names found in the selected column.', scoped: true }); + return; + } + + onDevicesParsed(deviceNames); + setFilePreview(null); + }; + + const handleCancelImport = () => { + setFilePreview(null); + }; + + return ( + + {filePreview && ( + + )} + + + + {shouldBlock && tooltipMessage ? ( + + + + + Import from CSV + + + +

{tooltipMessage}

+
+
+ ) : ( + + )} +
+ ); +}; diff --git a/src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx b/src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx new file mode 100644 index 0000000000..5c8bbbe95a --- /dev/null +++ b/src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx @@ -0,0 +1,312 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import { Label } from "@/components/ui/label"; + +import { useUpdateCohortDetails, useUpdateCohortName } from "@/core/hooks/useCohorts"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { buildCohortName, sanitizeCohortInput, splitCohortName } from "@/core/utils/cohortName"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; + +interface CohortDetailsModalProps { + open: boolean; + cohortDetails: { + name: string; + id: string; + visibility: boolean; + cohort_tags: string[]; + }; + onClose: () => void; +} + +const isStructuredCohortName = (name: string) => { + const parts = name.split("_").filter(Boolean); + return parts.length >= 2 && parts.length <= 3 && parts.every((part) => /^[a-z0-9]+$/.test(part)); +}; + +const CohortDetailsModal: React.FC = ({ + open, + cohortDetails, + onClose, +}) => { + const [form, setForm] = useState({ + name: "", + city: "", + projectName: "", + funder: "", + updateReason: "", + tags: [] as string[], + }); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const updateCohortName = useUpdateCohortName(); + const updateCohortDetails = useUpdateCohortDetails(); + const [showIgnoredTooltip, setShowIgnoredTooltip] = useState({ + city: false, + projectName: false, + funder: false, + }); + const tooltipTimers = useRef | null>>({}); + + useEffect(() => { + if (!open) return; + const { city, projectName, funder } = splitCohortName(cohortDetails.name || ""); + setForm({ + name: cohortDetails.name || "", + city, + projectName, + funder, + updateReason: "", + tags: cohortDetails.cohort_tags || [], + }); + }, [open, cohortDetails]); + + const handleCancel = () => { + const { city, projectName, funder } = splitCohortName(cohortDetails.name || ""); + setForm({ + name: cohortDetails.name || "", + city, + projectName, + funder, + updateReason: "", + tags: cohortDetails.cohort_tags || [], + }); + onClose(); + }; + + const handleSave = async () => { + const tagsChanged = + JSON.stringify(form.tags.slice().sort()) !== JSON.stringify((cohortDetails.cohort_tags || []).slice().sort()); + const isOrganizational = form.tags.includes("organizational"); + const trimmedName = form.name.trim(); + const trimmedCity = form.city.trim(); + const trimmedProject = form.projectName.trim(); + const trimmedReason = form.updateReason.trim(); + const legacyOrganizationalName = isOrganizational && !isStructuredCohortName(trimmedName); + const derivedName = isOrganizational + ? (legacyOrganizationalName ? trimmedName : buildCohortName(trimmedCity, trimmedProject, form.funder)) + : trimmedName; + const requiresUpdateReason = isOrganizational && derivedName !== cohortDetails.name; + if (!isOrganizational && trimmedName.length === 0) return; + if (isOrganizational && !legacyOrganizationalName && (trimmedCity.length === 0 || trimmedProject.length === 0)) return; + if (requiresUpdateReason && trimmedReason.length === 0) return; + const nameChanged = derivedName.length > 0 && derivedName !== cohortDetails.name; + + if (!nameChanged && !tagsChanged) return onClose(); + + try { + if (isOrganizational && nameChanged) { + await updateCohortName.mutateAsync({ + cohortId: cohortDetails.id, + name: derivedName, + updateReason: trimmedReason, + }); + if (tagsChanged) { + await updateCohortDetails.mutateAsync({ + cohortId: cohortDetails.id, + data: { cohort_tags: form.tags }, + }); + } + } else if (!isOrganizational && nameChanged) { + await updateCohortDetails.mutateAsync({ + cohortId: cohortDetails.id, + data: { name: derivedName, cohort_tags: form.tags }, + }); + } else if (tagsChanged) { + await updateCohortDetails.mutateAsync({ + cohortId: cohortDetails.id, + data: { cohort_tags: form.tags }, + }); + } + onClose(); + showBannerWithDelay({ + severity: 'success', + message: 'Cohort details updated successfully', + scoped: false, + }); + } catch (error) { + showBanner({ + severity: 'error', + message: `Failed to update cohort: ${getApiErrorMessage(error)}`, + scoped: true, + }); + } + }; + + const handleSanitizedInputChange = ( + fieldKey: "city" | "projectName" | "funder", + value: string + ) => { + const sanitized = sanitizeCohortInput(value); + if (/[^a-zA-Z0-9]/.test(value)) { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: true })); + if (tooltipTimers.current[fieldKey]) { + clearTimeout(tooltipTimers.current[fieldKey] as ReturnType); + } + tooltipTimers.current[fieldKey] = setTimeout(() => { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: false })); + }, 1500); + } + setForm((prev) => ({ ...prev, [fieldKey]: sanitized })); + }; + + const isOrganizational = form.tags.includes("organizational"); + const trimmedName = form.name.trim(); + const trimmedCity = form.city.trim(); + const trimmedProject = form.projectName.trim(); + const trimmedReason = form.updateReason.trim(); + const legacyOrganizationalName = isOrganizational && !isStructuredCohortName(trimmedName); + const derivedName = isOrganizational + ? (legacyOrganizationalName ? trimmedName : buildCohortName(trimmedCity, trimmedProject, form.funder)) + : trimmedName; + const tagsChanged = + JSON.stringify(form.tags.slice().sort()) !== JSON.stringify((cohortDetails.cohort_tags || []).slice().sort()); + const isSaving = updateCohortName.isPending || updateCohortDetails.isPending; + const requiresUpdateReason = isOrganizational && derivedName !== cohortDetails.name; + const canSave = + (isOrganizational + ? (legacyOrganizationalName + ? trimmedName.length > 0 + : trimmedCity.length > 0 && trimmedProject.length > 0) + : trimmedName.length > 0) && + (!requiresUpdateReason || trimmedReason.length > 0) && + derivedName.length > 0 && + (derivedName !== cohortDetails.name || tagsChanged); + + return ( + + Cancel + + {isSaving ? "Saving..." : "Save"} + +
+ } + > +
+
+ + setForm((s) => ({ ...s, tags: values }))} + value={form.tags} + allowCreate={false} + /> +
+ + {isOrganizational ? ( + <> + {legacyOrganizationalName ? ( + setForm((s) => ({ ...s, name: e.target.value }))} + placeholder="Enter cohort name" + disabled={isSaving} + required + description="Legacy cohort name detected. Update will preserve this format." + /> + ) : ( + <> +
+ + + +
+ handleSanitizedInputChange("city", e.target.value)} + placeholder="e.g. Nairobi" + disabled={isSaving} + required + /> +
+
+ +

Special character ignored

+
+
+
+ + + +
+ handleSanitizedInputChange("projectName", e.target.value)} + placeholder="e.g. WRI" + disabled={isSaving} + required + /> +
+
+ +

Special character ignored

+
+
+
+ + + +
+ handleSanitizedInputChange("funder", e.target.value)} + placeholder="e.g. EPIC" + disabled={isSaving} + /> +
+
+ +

Special character ignored

+
+
+
+
+
+ Cohort name will be: {derivedName || "-"} +
+ + )} + setForm((s) => ({ ...s, updateReason: e.target.value }))} + placeholder="Why is this name changing?" + disabled={isSaving} + required + /> + + ) : ( + setForm((s) => ({ ...s, name: e.target.value }))} + placeholder="Enter cohort name" + disabled={isSaving} + required + /> + )} +
+ + ); +}; + +export default CohortDetailsModal; diff --git a/src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx b/src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx new file mode 100644 index 0000000000..84d0cb3918 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useCohortDetails, useCohorts, useUnassignDevicesFromCohort } from "@/core/hooks/useCohorts"; +import { ComboBox } from "@/components/ui/combobox"; +import { MultiSelectCombobox, Option } from "@/components/ui/multi-select"; +import { Cohort } from "@/app/types/cohorts"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { Device } from "@/app/types/devices"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; + +interface UnassignCohortDevicesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDevices?: Device[]; + onSuccess?: () => void; + cohortId?: string; + cohortDevices?: Device[]; +} + +const formSchema = z.object({ + cohortId: z.string().min(1, { + message: "Please select a cohort.", + }), + devices: z.array(z.string()).min(1, { + message: "Please select at least one device.", + }), +}); + +export function UnassignCohortDevicesDialog({ + open, + onOpenChange, + selectedDevices, + onSuccess, + cohortId, + cohortDevices = [], +}: UnassignCohortDevicesDialogProps) { + const { cohorts } = useCohorts(); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const { mutate: unassignDevices, isPending: isUnassigning } = useUnassignDevicesFromCohort({ + onSuccess: (variables) => { + showBannerWithDelay({ + severity: 'success', + message: `${variables.device_ids.length} device(s) removed from cohort successfully`, + scoped: false, + }); + }, + onError: (error) => { + showBanner({ + severity: 'error', + message: `Failed to remove devices: ${getApiErrorMessage(error)}`, + scoped: true, + }); + }, + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + cohortId: cohortId || "", + devices: selectedDevices?.map(d => d._id).filter((id): id is string => !!id) || [], + }, + }); + + const deviceOptions: Option[] = useMemo(() => { + const devicesToShow = selectedDevices && selectedDevices.length > 0 ? selectedDevices : cohortDevices; + return devicesToShow + .map((device) => ({ + value: device._id || "", + label: device.long_name || device.name || `Device ${device._id}`, + })) + .filter((option) => option.value); + }, [cohortDevices, selectedDevices]); + + const watchedCohortId = form.watch("cohortId"); + const { data: fetchedCohort } = useCohortDetails( + watchedCohortId, + { enabled: !selectedDevices?.length && !cohortDevices.length && !!watchedCohortId } + ); + + const dynamicDeviceOptions: Option[] = useMemo(() => { + if (deviceOptions.length) return deviceOptions; + const list = fetchedCohort?.devices || []; + return list + .filter((d: Device) => !!d._id) + .map((d: Device) => ({ + value: d._id!, + label: d.long_name || d.name || `Device ${d._id}`, + })); + }, [deviceOptions, fetchedCohort]); + + useEffect(() => { + if (open) { + form.reset({ + cohortId: cohortId || "", + devices: selectedDevices?.map(d => d._id).filter((id): id is string => !!id) || [], + }); + } + }, [open, selectedDevices, form, cohortId]); + + function onSubmit(values: z.infer) { + unassignDevices( + { + cohortId: values.cohortId, + device_ids: values.devices, + }, + { + onSuccess: () => { + onOpenChange(false); + form.reset(); + onSuccess?.(); + }, + } + ); + } + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen); + if (!newOpen) { + form.reset(); + } + }; + + return ( + handleOpenChange(false)} + title="Remove devices from cohort" + subtitle={`${form.watch("devices")?.length || 0} device(s) selected`} + size="lg" + maxHeight="max-h-[70vh]" + primaryAction={{ + label: "Remove", + onClick: form.handleSubmit(onSubmit), + disabled: !form.watch("cohortId") || !form.watch("devices")?.length || isUnassigning, + }} + secondaryAction={{ + label: "Cancel", + onClick: () => handleOpenChange(false), + variant: "outline", + disabled: isUnassigning, + }} + > +
+ + ( + + + Cohort * + + + ({ + value: cohort._id, + label: cohort.name, + }))} + value={field.value} + onValueChange={field.onChange} + placeholder="Select a cohort" + searchPlaceholder="Search cohorts..." + emptyMessage="No cohorts found" + disabled={!!cohortId} + className="w-full" + allowCustomInput={false} + /> + + + + )} + /> + + ( + + + Devices * + + + + + + + )} + /> + + +
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx b/src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx new file mode 100644 index 0000000000..8d7a3e1cd9 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx @@ -0,0 +1,83 @@ +"use client"; + +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { Group } from "@/app/types/groups"; +import { useUnassignCohortsFromGroup } from "@/core/hooks/useCohorts"; + +interface UnassignCohortFromGroupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organization: Group | null; + cohortId: string; + cohortName: string; + onSuccess?: () => void; +} + +export function UnassignCohortFromGroupDialog({ + open, + onOpenChange, + organization, + cohortId, + cohortName, + onSuccess, +}: UnassignCohortFromGroupDialogProps) { + const { mutate: unassignFromGroup, isPending } = useUnassignCohortsFromGroup({ + onSuccess: () => { + onSuccess?.(); + }, + }); + + const handleConfirm = () => { + if (!organization) return; + + unassignFromGroup( + { + groupId: organization._id, + cohortIds: [cohortId], + }, + { + onSuccess: () => { + onOpenChange(false); + }, + } + ); + }; + + return ( + !isPending && onOpenChange(false)} + title="Unassign Organization" + size="md" + customFooter={ +
+ onOpenChange(false)} + disabled={isPending} + > + Cancel + + + {isPending ? "Unassigning..." : "Confirm Unassign"} + +
+ } + > +
+

+ Are you sure you want to unassign {organization?.grp_title} from cohort {cohortName}? +

+

+ This action will remove the organization's access to this cohort. This cannot be undone. +

+
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/dashboard/stat-card.tsx b/src/vertex-template/components/features/dashboard/stat-card.tsx new file mode 100644 index 0000000000..702ba5cb68 --- /dev/null +++ b/src/vertex-template/components/features/dashboard/stat-card.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Info } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useCallback } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface StatCardProps { + title: string; + value: number | string; + description?: string; + icon: React.ReactNode; + isLoading?: boolean; + variant?: "default" | "success" | "warning" | "destructive" | "info" | "primary"; + size?: "sm" | "md" | "lg"; + onClick?: () => void; + isActive?: boolean; +} + +export const StatCard = ({ + title, + value, + description, + icon, + isLoading, + variant = "default", + size = "md", + onClick, + isActive = false, +}: StatCardProps) => { + const getContainerStyles = useCallback(() => { + const baseStyles = "rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 relative overflow-hidden p-0 transition-all duration-200"; + + if (isLoading) { + return baseStyles; + } + + const interactiveStyles = onClick ? "cursor-pointer hover:shadow-md hover:border-primary/50" : ""; + const activeStyles = isActive ? "ring-2 ring-primary border-primary shadow-md" : ""; + return cn(baseStyles, interactiveStyles, activeStyles); + }, [onClick, isActive, isLoading]); + + const getIconColor = useCallback(() => { + switch (variant) { + case "success": + return "text-green-500 bg-green-50 dark:bg-green-900/10"; + case "warning": + return "text-yellow-500 bg-yellow-50 dark:bg-yellow-900/10"; + case "destructive": + return "text-red-500 bg-red-50 dark:bg-red-900/10"; + case "info": + return "text-blue-500 bg-blue-50 dark:bg-blue-900/10"; + case "primary": + return "text-primary bg-primary/10"; + default: + return "text-gray-500 bg-gray-50 dark:bg-gray-900/10"; + } + }, [variant]); + + // Size configurations + const sizeConfig = { + sm: { + padding: "p-3", + gap: "gap-2", + titleSize: "text-sm", + valueSize: "text-xl", + iconContainer: "p-1.5 rounded-lg", + iconSize: "w-4 h-4", + }, + md: { + padding: "p-4", + gap: "gap-4", + titleSize: "text-md", + valueSize: "text-3xl", + iconContainer: "p-2 rounded-xl", + iconSize: "w-6 h-6", + }, + lg: { + padding: "p-6", + gap: "gap-6", + titleSize: "text-lg", + valueSize: "text-4xl", + iconContainer: "p-3 rounded-2xl", + iconSize: "w-8 h-8", + }, + }; + + const config = sizeConfig[size]; + + if (isLoading) { + return ( +
+ + +
+ + {description && } +
+ +
+
+
+ ); + } + + // Clone icon to apply size classes if it's a valid element + // But since we pass icon as a node, we might need to wrap it or expect correct size + // For flexibility, we'll suggest passing icon with correct size or override here if possible + // Using a wrapper div to constrain size might be safer if the icon is SVG + + return ( +
+ { + if (onClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick(); + } + }} + role={onClick ? "button" : undefined} + tabIndex={onClick ? 0 : undefined} + aria-label={onClick ? `View ${title.toLowerCase()}` : undefined} + > + +
+
+
+ {title} +
+ {description && ( + + + + + + +

{description}

+
+
+
+ )} +
+
+
+ + {value} + +
+ {icon} +
+
+
+
+
+ ); +}; diff --git a/src/vertex-template/components/features/dashboard/stats-cards.tsx b/src/vertex-template/components/features/dashboard/stats-cards.tsx new file mode 100644 index 0000000000..9b10f58ffd --- /dev/null +++ b/src/vertex-template/components/features/dashboard/stats-cards.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useDeviceCount } from "@/core/hooks/useDevices"; +import { usePersonalUserCohorts } from "@/core/hooks/useCohorts"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { + AqMonitor, + AqCollocation, + AqWifiOff, + AqData, +} from "@airqo/icons-react"; +import { useMemo } from "react"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { useAppSelector } from "@/core/redux/hooks"; +import { getStatusExplanation } from "@/core/utils/status"; +import { StatCard } from "./stat-card"; + +export const DashboardStatsCards = () => { + const { data: session } = useSession(); + const { userScope } = useUserContext(); + const user = useAppSelector((state) => state.user.userDetails); + + const isPersonalScope = userScope === 'personal'; + const userId = (session?.user as { id?: string })?.id || user?._id; + + // Fetch personal user cohorts + const { data: personalCohortIds = [], isLoading: isLoadingPersonalCohorts } = usePersonalUserCohorts( + userId, + { enabled: !!userId && isPersonalScope } + ); + + const shouldEnable = isPersonalScope ? personalCohortIds.length > 0 : true; + + // Use useDeviceCount for both scopes + // For personal scope, pass the user's cohort IDs + // If personal scope and no cohorts, the hook is disabled + const deviceCountQuery = useDeviceCount({ + enabled: shouldEnable, + cohortIds: isPersonalScope ? personalCohortIds : undefined, + }); + + const isLoading = (isPersonalScope && isLoadingPersonalCohorts) || (shouldEnable && deviceCountQuery.isLoading); + + const metrics = useMemo(() => { + const summary = deviceCountQuery.data?.summary; + if (summary) { + return { + total: summary.total_monitors, + operational: summary.operational, + transmitting: summary.transmitting, + notTransmitting: summary.not_transmitting, + dataAvailable: summary.data_available, + }; + } + + return { + total: 0, + operational: 0, + transmitting: 0, + notTransmitting: 0, + dataAvailable: 0, + }; + }, [deviceCountQuery.data]); + + const router = useRouter(); + + const handleNavigation = (queryString: string) => { + const basePath = isPersonalScope ? "/devices/my-devices" : "/devices/overview"; + router.push(`${basePath}${queryString}`); + }; + + return ( +
+
+ } + isLoading={isLoading} + onClick={() => handleNavigation('')} + variant="primary" + size="md" + /> + + } + isLoading={isLoading} + onClick={() => handleNavigation('?status=operational')} + variant="success" + size="md" + /> + + } + isLoading={isLoading} + onClick={() => handleNavigation('?status=transmitting')} + variant="info" + size="md" + /> + + } + isLoading={isLoading} + onClick={() => handleNavigation('?status=not_transmitting')} + variant="default" + size="md" + /> + + } + isLoading={isLoading} + onClick={() => handleNavigation('?status=data_available')} + variant="warning" + size="md" + /> +
+
+ ); +}; diff --git a/src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx b/src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx new file mode 100644 index 0000000000..16a920c32a --- /dev/null +++ b/src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx @@ -0,0 +1,152 @@ +"use client"; + +import React, { useState } from "react"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { DatePicker } from "@/components/ui/date-picker"; +import { useAddMaintenanceLog } from "@/core/hooks/useDevices"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { Switch } from "@/components/ui/switch"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { useBanner } from "@/context/banner-context"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; + +interface AddMaintenanceLogModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + deviceName: string; +} + +interface Option { + value: string + label: string +} + +const createTagOption = (label: string): Option => ({ + value: label.toLowerCase(), + label: label, +}) + +const DEFAULT_TAGS: Option[] = [ + createTagOption("Dust blowing and sensor cleaning"), + createTagOption("Site update check"), + createTagOption("Device equipment check"), + createTagOption("Power circuitry and components works"), + createTagOption("GPS module works/replacement"), + createTagOption("GSM module works/replacement"), + createTagOption("Battery works/replacement"), + createTagOption("Power supply works/replacement"), + createTagOption("Antenna works/replacement"), + createTagOption("Mounts replacement"), + createTagOption("Software checks/re-installation"), + createTagOption("PCB works/replacement"), + createTagOption("Temp/humidity sensor works/replacement"), + createTagOption("Air quality sensor(s) works/replacement"), +] + +const AddMaintenanceLogModal: React.FC = ({ open, onOpenChange, deviceName }) => { + const [date, setDate] = useState(new Date()); + const [description, setDescription] = useState(""); + const [selectedTags, setSelectedTags] = useState([]) + const [maintenanceType, setMaintenanceType] = useState<"preventive" | "corrective">("preventive"); + + const { userDetails } = useUserContext(); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const addMaintenanceLog = useAddMaintenanceLog(); + + // Reset form when modal opens/closes + React.useEffect(() => { + if (!open) { + setDate(new Date()); + setDescription(""); + setSelectedTags([]); + setMaintenanceType("preventive"); + } + }, [open]); + + const handleSubmit = async () => { + if (!date || selectedTags.length === 0) { + showBanner({ severity: 'error', message: 'Please fill all required fields', scoped: true }); + return; + } + + const logData = { + date: date.toISOString(), + tags: selectedTags.map(tag => tag.toLowerCase()), + description, + userName: userDetails?.email || "", + maintenanceType, + email: userDetails?.email || "", + firstName: userDetails?.firstName || "", + lastName: userDetails?.lastName || "", + user_id: userDetails?._id || "", + }; + + try { + await addMaintenanceLog.mutateAsync({ deviceName, logData }); + showBannerWithDelay({ + severity: 'success', + title: 'Success', + message: `Maintenance log for ${deviceName} has been added successfully.`, + scoped: false + }, 300); + onOpenChange(false); + } catch (error) { + showBanner({ severity: 'error', message: `Failed to Add Maintenance Log: ${getApiErrorMessage(error)}`, scoped: true }); + } + }; + + return ( + onOpenChange(false)} + title={`Add Maintenance Log for ${deviceName}`} + className="w-[70vw] h-[70vh] max-w-none max-h-none m-0 p-0" + primaryAction={{ + label: addMaintenanceLog.isPending ? "Saving..." : "Save Log", + onClick: handleSubmit, + disabled: addMaintenanceLog.isPending, + }} + secondaryAction={{ + label: "Cancel", + onClick: () => onOpenChange(false), + variant: "outline", + disabled: addMaintenanceLog.isPending, + }} + > +
+
+ + Preventive + setMaintenanceType(checked ? 'corrective' : 'preventive')} + /> + Corrective +
+
+ + +
+
+ + +
+
+ +