diff --git a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml index 258246f0ee..37ca8d2cec 100644 --- a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml +++ b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml @@ -1,7 +1,7 @@ replicaCount: 2 image: repository: airqoacr.azurecr.io/airqo-vertex - tag: prod-726b5a3b-1780565472 + tag: prod-1786c96c-1780691136 pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: '' diff --git a/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml b/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml index 8821059a2b..0344355133 100644 --- a/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml +++ b/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml @@ -1,7 +1,7 @@ replicaCount: 2 image: repository: airqoacr.azurecr.io/airqo-stage-vertex - tag: stage-ce0f32db-1780506966 + tag: stage-d3209b04-1780831845 pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: '' diff --git a/src/internal-docs/.env.example b/src/internal-docs/.env.example new file mode 100644 index 0000000000..fcadf84287 --- /dev/null +++ b/src/internal-docs/.env.example @@ -0,0 +1,43 @@ +# ------------------------------------------------------------------------- +# BookStack Environment Configuration +# ------------------------------------------------------------------------- +# Copy this file to `.env` before running `docker compose up -d`. +# Do not commit the `.env` file to version control. +# ------------------------------------------------------------------------- + +# The base URL of your BookStack installation. +# Must include the protocol (http:// or https://) +APP_URL=http://localhost:6875 + +# The name of the application (Shows in the header and emails) +APP_NAME="Mariana Deep" + +# Disable public access to enforce authentication for all pages +# Set to 'true' if you want anonymous users to read documentation +APP_PUBLIC=false + +# Application Key (Required for sessions and encryption) +# Generate a new one for production using: docker run --rm --entrypoint /bin/bash lscr.io/linuxserver/bookstack:latest appkey +APP_KEY= + +# ------------------------------------------------------------------------- +# Database Configuration +# ------------------------------------------------------------------------- +# Ensure these match the values used in docker-compose.yml. +DB_HOST=bookstack_db +DB_DATABASE=bookstackapp +DB_USER=bookstack +DB_PASSWORD=your_secure_db_password +DB_ROOT_PASSWORD=your_secure_root_password + +# ------------------------------------------------------------------------- +# Mail Settings (Optional but recommended for user invitations/resets) +# ------------------------------------------------------------------------- +# MAIL_DRIVER=smtp +# MAIL_HOST=smtp.mailtrap.io +# MAIL_PORT=2525 +# MAIL_USERNAME=null +# MAIL_PASSWORD=null +# MAIL_ENCRYPTION=null +# MAIL_FROM=docs@yourdomain.com +# MAIL_FROM_NAME="Internal Docs" diff --git a/src/internal-docs/.gitignore b/src/internal-docs/.gitignore new file mode 100644 index 0000000000..41813ebc80 --- /dev/null +++ b/src/internal-docs/.gitignore @@ -0,0 +1,9 @@ +# Ignore environment variables file +.env + +# Ignore Docker volumes for BookStack application state (uploads, themes, etc.) +config/ + +# Ignore Docker volumes for MariaDB database files +db_data/ +db_data_v2/ diff --git a/src/internal-docs/README.md b/src/internal-docs/README.md new file mode 100644 index 0000000000..4b8fa284e6 --- /dev/null +++ b/src/internal-docs/README.md @@ -0,0 +1,99 @@ +# Mariana Deep - Internal Documentation + +This repository contains the containerized environment and Kubernetes Helm charts for **Mariana Deep**, AirQo's internal documentation platform powered by [BookStack](https://www.bookstackapp.com/). + +## Table of Contents +- [Local Setup & Testing](#local-setup--testing) +- [Kubernetes Deployment](#kubernetes-deployment) +- [Creating & Editing Documentation](#creating--editing-documentation) +- [Information Architecture](#information-architecture-product-focused) +- [Contributing Guidelines](#contributing-guidelines) +- [Security & Data Persistence](#security--data-persistence) + +## Local Setup & Testing + +To spin up the documentation platform locally for testing or local development: + +1. **Configure the Environment** + Duplicate the provided example environment file: + + *macOS / Linux:* + ```bash + cp .env.example .env + ``` + *Windows (Command Prompt / PowerShell):* + ```cmd + copy .env.example .env + ``` + +2. **Start the Containers** + Use Docker Compose to build and start the environment in the background: + ```bash + docker compose up -d + ``` + +3. **Access the Application** + Wait approximately 20 seconds for database migrations to complete. + Open your browser and navigate to `http://localhost:6875`. + *(Default credentials on first run: `admin@admin.com` / `password`)* + +## Kubernetes Deployment + +The project uses Helm charts located in the `k8s/` directory to deploy to our production cluster. + +### Environments +- **Production**: Accessible at `https://platform.airqo.net/mariana` + +### Deployment Commands +To deploy or upgrade the cluster, run the following Helm commands from the root of this project: + +**Production:** +```bash +helm upgrade --install airqo-internal-docs k8s/ -f k8s/values-prod.yaml -n production +``` + +## Creating & Editing Documentation + +BookStack features an intuitive WYSIWYG editor, making it incredibly easy for any technical contributor to document features, APIs, and runbooks without fighting markdown syntax (though Markdown is fully supported!). + +### How to Create Content +1. **Navigate to the appropriate Book**: Look at the Information Architecture below to find exactly where your document belongs. +2. **Add a Chapter or Page**: Click the "New Page" or "New Chapter" button in the right sidebar menu. +3. **Use the Editor**: Write your documentation. You can drag-and-drop images directly into the editor. If you prefer raw markdown, you can switch the editor type in your user settings. +4. **Link Internal Content**: Use the "Link" button to easily search and interlink to other existing internal documents. + +### Best Practices for Documentation +- **Keep it Concise**: Use bullet points, clear headings, and avoid massive walls of text. Scannability is key. +- **Keep it Updated**: The Boy Scout Rule applies—if you change a system's behavior, update its documentation in the exact same sprint. +- **Use Code Blocks**: When documenting CLI commands, scripts, or JSON payloads, use properly formatted code blocks. +- **Tagging**: Use tags on your pages (e.g., `api`, `deprecated`, `v2`) to make them highly searchable across the platform. + +## Information Architecture (Product-Focused) + +We organize all of our documentation using a **Product-focused approach**. Whether it's a mobile app, a microservice, or a data pipeline, every major initiative is treated as a Product. Strictly adhere to the following hierarchy: + +1. **Shelves (High-Level Domains)**: Groups related products under a larger domain or suite. + *(Examples: AirQo Platform, Data Pipelines, Mobile Apps, Infrastructure)* +2. **Books (The Products)**: Represents a specific Product, Microservice, or distinct Tool. + *(Examples: Netmanager, Beacon, AirQo API, Ingestion Pipeline)* +3. **Chapters (Major Components)**: Groups related documentation topics within a specific Product. + *(Examples: Architecture, API Reference, Deployment Guides, Runbooks)* +4. **Pages (Specific Documents)**: The granular, individual pieces of documentation. + *(Examples: Authentication Flow Diagram, GET /v1/devices, How to run the calibration script)* + +**Example Flow:** +`Shelves (AirQo Platform) -> Book (Netmanager) -> Chapter (API Reference) -> Page (Authentication Endpoint)` + +## Contributing Guidelines + +We encourage all engineers to actively contribute to Mariana Deep. If you see something wrong, fix it! + +- **Typos and Minor Fixes**: Fix them immediately using the BookStack web interface. No review needed. +- **Major Architectural Documentation**: Draft the documentation in a Page and request a review (via Slack or email link) from the Lead Engineer of that specific product before finalizing. +- **Infrastructure Changes**: If you are modifying the Helm charts or Docker Compose configurations in this repository, please create a feature branch, test locally using Docker Compose, and open a standard Pull Request on GitHub. + +## Security & Data Persistence + +- **Public Access**: Explicitly disabled (`APP_PUBLIC=false`). Unauthenticated users cannot view any pages and will be immediately redirected to the login screen. +- **User Management**: Open registration is disabled. Administrators manually provision access. +- **Data Persistence**: In Kubernetes, Persistent Volume Claims (PVCs) ensure uploaded images and database state survive pod restarts. Locally, BookStack configuration and uploads are stored in the bind-mounted `./config` directory, while database data persists in the Docker-managed `bookstack_db_data` volume. diff --git a/src/internal-docs/docker-compose.yml b/src/internal-docs/docker-compose.yml new file mode 100644 index 0000000000..c07c985d64 --- /dev/null +++ b/src/internal-docs/docker-compose.yml @@ -0,0 +1,52 @@ +version: "3.8" + +services: + bookstack: + image: lscr.io/linuxserver/bookstack:24.05.2 + container_name: bookstack + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + - APP_NAME=${APP_NAME:-"Mariana Deep"} + - APP_URL=${APP_URL:-http://localhost:6875} + - APP_KEY=${APP_KEY:-} + - DB_HOST=bookstack_db + - DB_PORT=3306 + - DB_USER=${DB_USER:-bookstack} + - DB_PASS=${DB_PASSWORD:-bookstack_secure_password} + - DB_DATABASE=${DB_DATABASE:-bookstackapp} + # Security & Access Control phase settings: + - APP_PUBLIC=${APP_PUBLIC:-false} # Disable public viewing by default + volumes: + - ./config:/config + ports: + - "6875:80" + restart: unless-stopped + depends_on: + - bookstack_db + networks: + - internal-docs-network + + bookstack_db: + image: mariadb:10.6 + container_name: bookstack_db + environment: + - TZ=UTC + - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-root_secure_password} + - MYSQL_DATABASE=${DB_DATABASE:-bookstackapp} + - MYSQL_USER=${DB_USER:-bookstack} + - MYSQL_PASSWORD=${DB_PASSWORD:-bookstack_secure_password} + volumes: + - bookstack_db_data:/var/lib/mysql + restart: unless-stopped + networks: + - internal-docs-network + +networks: + internal-docs-network: + name: internal-docs-network + driver: bridge + +volumes: + bookstack_db_data: diff --git a/src/internal-docs/k8s/Chart.yaml b/src/internal-docs/k8s/Chart.yaml new file mode 100644 index 0000000000..1210342f67 --- /dev/null +++ b/src/internal-docs/k8s/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +appVersion: "24.05.2" +description: AirQo Internal Docs (Mariana Deep) Helm Chart +name: airqo-internal-docs +home: https://airqo.net +version: 0.1.0 +maintainers: + - name: AirQo + email: support@airqo.net + url: https://airqo.net diff --git a/src/internal-docs/k8s/templates/_helpers.tpl b/src/internal-docs/k8s/templates/_helpers.tpl new file mode 100644 index 0000000000..75f1964af3 --- /dev/null +++ b/src/internal-docs/k8s/templates/_helpers.tpl @@ -0,0 +1,49 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "internal-docs.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "internal-docs.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "internal-docs.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "internal-docs.labels" -}} +helm.sh/chart: {{ include "internal-docs.chart" . }} +{{ include "internal-docs.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "internal-docs.selectorLabels" -}} +app.kubernetes.io/name: {{ include "internal-docs.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/src/internal-docs/k8s/templates/configmap.yaml b/src/internal-docs/k8s/templates/configmap.yaml new file mode 100644 index 0000000000..ee4e549c61 --- /dev/null +++ b/src/internal-docs/k8s/templates/configmap.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.app.configmap }} + namespace: {{ .Values.app.namespace }} + labels: + {{- include "internal-docs.labels" . | nindent 4 }} +data: + # BookStack Specific + APP_NAME: {{ .Values.env.APP_NAME | quote }} + APP_PUBLIC: {{ .Values.env.APP_PUBLIC | quote }} + + # Shared Database Configuration (Used by BookStack container) + DB_USER: {{ .Values.env.DB_USER | quote }} + DB_DATABASE: {{ .Values.env.DB_DATABASE | quote }} + + # MariaDB Specific (Used by MariaDB container) + MYSQL_USER: {{ .Values.env.DB_USER | quote }} + MYSQL_DATABASE: {{ .Values.env.DB_DATABASE | quote }} diff --git a/src/internal-docs/k8s/templates/deployment-app.yaml b/src/internal-docs/k8s/templates/deployment-app.yaml new file mode 100644 index 0000000000..e41a9f4441 --- /dev/null +++ b/src/internal-docs/k8s/templates/deployment-app.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.app.name }} + namespace: {{ .Values.app.namespace }} + labels: + {{- include "internal-docs.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: {{ .Values.app.label }} + template: + metadata: + labels: + app: {{ .Values.app.label }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Values.app.label }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + - name: DB_HOST + value: {{ .Values.dbApp.name }} + - name: APP_URL + value: {{ .Values.app.url | quote }} + envFrom: + - configMapRef: + name: {{ .Values.app.configmap }} + - secretRef: + name: {{ .Values.app.configmap }}-secret + volumeMounts: + - name: bookstack-config + mountPath: /config + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: bookstack-config + persistentVolumeClaim: + claimName: {{ .Values.app.name }}-pvc + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/src/internal-docs/k8s/templates/deployment-db.yaml b/src/internal-docs/k8s/templates/deployment-db.yaml new file mode 100644 index 0000000000..9060bedd09 --- /dev/null +++ b/src/internal-docs/k8s/templates/deployment-db.yaml @@ -0,0 +1,75 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.dbApp.name }} + namespace: {{ .Values.app.namespace }} + labels: + {{- include "internal-docs.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Values.dbApp.label }} + template: + metadata: + labels: + app: {{ .Values.dbApp.label }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Values.dbApp.label }} + image: "{{ .Values.dbImage.repository }}:{{ .Values.dbImage.tag }}" + imagePullPolicy: {{ .Values.dbImage.pullPolicy }} + ports: + - name: mysql + containerPort: {{ .Values.dbService.targetPort }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Values.app.configmap }} + - secretRef: + name: {{ .Values.app.configmap }}-secret + volumeMounts: + - name: mariadb-config + mountPath: /var/lib/mysql + livenessProbe: + exec: + command: + - sh + - -c + - mysqladmin ping -h 127.0.0.1 -uroot -p"$MYSQL_ROOT_PASSWORD" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + exec: + command: + - sh + - -c + - mysql -h 127.0.0.1 -uroot -p"$MYSQL_ROOT_PASSWORD" -e 'SELECT 1' + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 2 + resources: + {{- toYaml .Values.dbResources | nindent 12 }} + volumes: + - name: mariadb-config + persistentVolumeClaim: + claimName: {{ .Values.dbApp.name }}-pvc + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/src/internal-docs/k8s/templates/hpa.yaml b/src/internal-docs/k8s/templates/hpa.yaml new file mode 100644 index 0000000000..9803305c14 --- /dev/null +++ b/src/internal-docs/k8s/templates/hpa.yaml @@ -0,0 +1,23 @@ +{{- if .Values.autoscaling }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ .Values.app.name }} + namespace: {{ .Values.app.namespace }} + labels: + {{- include "internal-docs.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ .Values.app.name }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/src/internal-docs/k8s/templates/pvc.yaml b/src/internal-docs/k8s/templates/pvc.yaml new file mode 100644 index 0000000000..7139a8a19a --- /dev/null +++ b/src/internal-docs/k8s/templates/pvc.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Values.app.name }}-pvc + namespace: {{ .Values.app.namespace }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Values.dbApp.name }}-pvc + namespace: {{ .Values.app.namespace }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.dbPersistence.storageClass }} + storageClassName: {{ .Values.dbPersistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.dbPersistence.size }} diff --git a/src/internal-docs/k8s/templates/secret.yaml b/src/internal-docs/k8s/templates/secret.yaml new file mode 100644 index 0000000000..c2e4d895aa --- /dev/null +++ b/src/internal-docs/k8s/templates/secret.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.app.configmap }}-secret + namespace: {{ .Values.app.namespace }} + labels: + {{- include "internal-docs.labels" . | nindent 4 }} +type: Opaque +stringData: + APP_KEY: {{ required "env.APP_KEY must be set (inject via CI/CD/Secrets)" .Values.env.APP_KEY | quote }} + DB_PASS: {{ required "env.DB_PASSWORD must be set" .Values.env.DB_PASSWORD | quote }} + DB_PASSWORD: {{ .Values.env.DB_PASSWORD | quote }} + MYSQL_PASSWORD: {{ .Values.env.DB_PASSWORD | quote }} + MYSQL_ROOT_PASSWORD: {{ required "env.MYSQL_ROOT_PASSWORD must be set" .Values.env.MYSQL_ROOT_PASSWORD | quote }} diff --git a/src/internal-docs/k8s/templates/service-app.yaml b/src/internal-docs/k8s/templates/service-app.yaml new file mode 100644 index 0000000000..594d4a3ccb --- /dev/null +++ b/src/internal-docs/k8s/templates/service-app.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.app.name }} + namespace: {{ .Values.app.namespace }} + labels: + {{- include "internal-docs.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + {{- if eq .Values.service.type "NodePort" }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + selector: + app: {{ .Values.app.label }} diff --git a/src/internal-docs/k8s/templates/service-db.yaml b/src/internal-docs/k8s/templates/service-db.yaml new file mode 100644 index 0000000000..034b9a85b1 --- /dev/null +++ b/src/internal-docs/k8s/templates/service-db.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.dbApp.name }} + namespace: {{ .Values.app.namespace }} + labels: + {{- include "internal-docs.labels" . | nindent 4 }} +spec: + type: {{ .Values.dbService.type }} + ports: + - port: {{ .Values.dbService.port }} + targetPort: {{ .Values.dbService.targetPort }} + protocol: TCP + name: mysql + selector: + app: {{ .Values.dbApp.label }} diff --git a/src/internal-docs/k8s/values-prod.yaml b/src/internal-docs/k8s/values-prod.yaml new file mode 100644 index 0000000000..0593a9bbdc --- /dev/null +++ b/src/internal-docs/k8s/values-prod.yaml @@ -0,0 +1,91 @@ +replicaCount: 2 +image: + repository: lscr.io/linuxserver/bookstack + tag: 24.05.2 + pullPolicy: IfNotPresent +dbImage: + repository: mariadb + tag: 10.6 + pullPolicy: IfNotPresent +imagePullSecrets: [] +nameOverride: '' +fullnameOverride: '' + +service: + type: NodePort + port: 6875 + targetPort: 80 + nodePort: 31197 # Unique NodePort for production + +dbService: + type: ClusterIP + port: 3306 + targetPort: 3306 + +ingress: + enabled: false + +resources: + limits: + cpu: 500m + memory: 1Gi + requests: + cpu: 100m + memory: 512Mi + +dbResources: + limits: + cpu: 500m + memory: 1Gi + requests: + cpu: 100m + memory: 512Mi + +autoscaling: + minReplicas: 2 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + +nodeSelector: + role: high-mem + +tolerations: [] + +affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - preference: + matchExpressions: + - key: role + operator: In + values: + - high-mem + weight: 1 + +app: + name: airqo-prod-internal-docs + label: prod-internal-docs + namespace: production + configmap: env-internal-docs-prod + url: "https://platform.airqo.net/mariana" + +dbApp: + name: airqo-prod-internal-docs-db + label: prod-internal-docs-db + +persistence: + size: 5Gi + storageClass: "fast-ssd" + +dbPersistence: + size: 20Gi + storageClass: "fast-ssd" + +env: + APP_NAME: "Mariana Deep" + APP_KEY: "" # Must be securely injected via CI/CD or secrets management + APP_PUBLIC: "false" + DB_USER: "bookstack" + DB_PASSWORD: "" # Must be securely injected + DB_DATABASE: "bookstackapp" + MYSQL_ROOT_PASSWORD: "" # Must be securely injected diff --git a/src/vertex/.env.example b/src/vertex/.env.example index fb0189198a..0e7944f921 100644 --- a/src/vertex/.env.example +++ b/src/vertex/.env.example @@ -6,7 +6,7 @@ NEXT_PUBLIC_SLACK_CHANNEL=notifs-airqo-netmanager-web SLACK_WEBHOOK_URL= NEXT_PUBLIC_API_TOKEN= -NEXT_PUBLIC_API_URL=https://staging-analytics.airqo.net/api/v2/ +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= diff --git a/src/vertex/app/(authenticated)/home/page.tsx b/src/vertex/app/(authenticated)/home/page.tsx index d17da88564..fa0bf88607 100644 --- a/src/vertex/app/(authenticated)/home/page.tsx +++ b/src/vertex/app/(authenticated)/home/page.tsx @@ -4,7 +4,7 @@ import React from "react"; import dynamic from "next/dynamic"; import { useSession } from "next-auth/react"; import { Plus, Upload, AlertTriangle } from "lucide-react"; -import { useAppSelector } from "@/core/redux/hooks"; +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"; @@ -20,8 +20,13 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/ 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. @@ -131,6 +136,15 @@ const WelcomePage = () => { 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; @@ -144,34 +158,177 @@ const WelcomePage = () => { ? `org_${activeGroup._id}` : null; - // ── Checklist state (frontend-only, localStorage-backed) ────────────────── - const [checklistState, setChecklistState] = React.useState(() => + 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 ?? "") ); - // Re-sync when the active workspace changes React.useEffect(() => { - if (orgId) { - setChecklistState(getChecklistState(orgId)); + 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, + }; } - }, [orgId]); + return localChecklistState; + }, [userScope, activeGroup?.onboarding_checklist, groupDetails?.onboarding_checklist, localChecklistState]); - const updateChecklist = React.useCallback( - (patch: Partial<{ completedSteps: string[]; dismissed: boolean }>) => { - setChecklistState((prev) => { - const next = { ...prev, ...patch }; - if (orgId) saveChecklistState(orgId, next); + // ── 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] + [orgId, userScope, activeGroup?._id, dispatch, activeChecklistState.completedSteps, autoSteps, queryClient, updateGroupOnboarding] ); const openAddDeviceChoice = React.useCallback(() => { setIsAddDeviceChoiceOpen(true); }, []); - // Scroll to and highlight the Device Visibility accordion section const handleGoToVisibility = React.useCallback(() => { setAccordionItems(prev => prev.includes("visibility") ? prev : [...prev, "visibility"] @@ -215,92 +372,35 @@ const WelcomePage = () => { if (deviceInfo?.isCohortImport || deviceInfo?.cohortId) { setNewlyClaimedDevice(undefined); updateChecklist({ - completedSteps: Array.from( - new Set([...(checklistState.completedSteps || []), "add-device", "assign-cohort"]), - ), + 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({ - completedSteps: Array.from( - new Set([...(checklistState.completedSteps || []), "add-device"]), - ), + action: 'mark_step_complete', + step_id: 'add-device', + completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "add-device"])), }); } }, - [checklistState.completedSteps, refreshHomeData, updateChecklist] + [activeChecklistState.completedSteps, refreshHomeData, updateChecklist] ); const handleCohortAssigned = React.useCallback(() => { updateChecklist({ - completedSteps: Array.from( - new Set([...(checklistState.completedSteps || []), "assign-cohort"]), - ), + action: 'mark_step_complete', + step_id: 'assign-cohort', + completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "assign-cohort"])), }); setNewlyClaimedDevice(undefined); - }, [checklistState.completedSteps, updateChecklist]); - - // ── 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. - React.useEffect(() => { - if (!orgId) return; - - const hasDevices = - userScope === "personal" - ? (myDevicesData?.devices ?? []).length > 0 - : groupDevices.length > 0; - - const hasCohorts = - userScope === "personal" - ? (personalCohortIds ?? []).length > 0 - : (groupCohortIds ?? []).length > 0; + }, [activeChecklistState.completedSteps, updateChecklist]); - const autoSteps: string[] = []; - if (hasDevices) { - autoSteps.push("add-device", "assign-cohort"); - } else if (hasCohorts) { - autoSteps.push("assign-cohort"); - } - if (autoSteps.length === 0) return; - setChecklistState((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, next); - return next; - }); - }, [orgId, userScope, myDevicesData, groupDevices, personalCohortIds, groupCohortIds]); // ── Early returns ────────────────────────────────────────────────────────── @@ -327,8 +427,9 @@ const WelcomePage = () => { // The checklist stays visible as long as: // 1. Not all steps are complete, AND // 2. The user hasn't explicitly dismissed it - const allStepsComplete = checklistState.completedSteps.length >= TOTAL_STEPS; - const showChecklist = !allStepsComplete && !checklistState.dismissed; + const allStepsComplete = visuallyCompletedSteps.length >= TOTAL_STEPS; + const isLoadingGroupDetailsSafe = userScope === "organisation" && isLoadingGroupDetails; + const showChecklist = !allStepsComplete && !activeChecklistState.dismissed && !isLoadingGroupDetailsSafe; const showClaimDevice = (() => { switch (userContext) { @@ -341,7 +442,6 @@ const WelcomePage = () => { const renderSharedModals = () => ( <> - {/* Always rendered — controlled by open prop so sibling positions stay stable */} { @@ -444,8 +544,6 @@ const WelcomePage = () => { ); } - - const renderMainContent = () => { if (hasNoDevices) { return ( @@ -453,8 +551,8 @@ const WelcomePage = () => { {showChecklist && ( updateChecklist({ dismissed: true })} + completedSteps={visuallyCompletedSteps} + onDismiss={() => updateChecklist({ action: 'dismiss_checklist', dismissed: true })} onAddDevice={openAddDeviceChoice} onGoToCohorts={() => setIsAssignCohortModalOpen(true)} onGoToVisibility={handleGoToVisibility} @@ -473,8 +571,8 @@ const WelcomePage = () => { {showChecklist && ( updateChecklist({ dismissed: true })} + completedSteps={visuallyCompletedSteps} + onDismiss={() => updateChecklist({ action: 'dismiss_checklist', dismissed: true })} onAddDevice={openAddDeviceChoice} onGoToCohorts={() => setIsAssignCohortModalOpen(true)} onGoToVisibility={handleGoToVisibility} @@ -545,13 +643,15 @@ const WelcomePage = () => { showCoachMark={highlightVisibility} onVisibilityChanged={() => { const nextCompletedSteps = Array.from( - new Set([...(checklistState.completedSteps || []), "set-visibility"]) + new Set([...visuallyCompletedSteps, "set-visibility"]) ); const justCompletedSetup = - !checklistState.completedSteps.includes("set-visibility") && + !visuallyCompletedSteps.includes("set-visibility") && nextCompletedSteps.length >= TOTAL_STEPS; updateChecklist({ + action: 'mark_step_complete', + step_id: 'set-visibility', completedSteps: nextCompletedSteps, }); @@ -560,6 +660,11 @@ const WelcomePage = () => { 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); } }} /> diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 27e81b9064..131d459452 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -4,6 +4,81 @@ --- +## Version 1.23.61 +**Released:** June 07, 2026 + +### Centralized Onboarding State & API Abstraction + +Migrated the onboarding checklist state from local storage to a centralized backend API for the Organization context, solving cross-device synchronization issues for enterprise users. (Note: Personal context continues to use local storage pending backend API rollout). + +
+Onboarding API Integration (4) + +- **Backend Synchronization:** Deprecated `localStorage` as the single source of truth for the organization-level onboarding checklist (`add-device`, `assign-cohort`, `set-visibility`). State is now inherently bound to the `Group` document. +- **Dynamic State Resolution:** The frontend now relies on the API to dynamically evaluate resource availability (e.g., existing devices or cohorts) and pre-populate completed steps during `GET /users/groups/:groupId` requests. +- **JIT Patching & Race Condition Fix:** Implemented serialized `PATCH /api/v1/users/groups/:groupId/onboarding` requests for missing manual steps to prevent NextAuth session race conditions and unexpected lockouts. +- **API Hook Abstraction:** Refactored direct API proxy invocations into strongly-typed custom React Query hooks (`useGroupDetails` and `useUpdateGroupOnboarding`) to centralize query management and reduce Axios instantiation overhead. + +
+ +
+Files Modified (3) + +- `app/(authenticated)/home/page.tsx` [MODIFIED] +- `core/apis/organizations.ts` [MODIFIED] +- `core/hooks/useGroups.ts` [MODIFIED] + +
+ +--- + +## Version 1.23.60 +**Released:** June 04, 2026 + +### Multi-Provider Social Auth, API Proxy & Role Permissions + +Introduced comprehensive multi-provider social authentication, updated API proxy routing, enhanced hCaptcha environment support, and fixed missing network view permissions for system administrators. + +
+Social Authentication & Routing (4) + +- **Multi-Provider Social Auth**: Replaced the standalone Google auth component with a unified `SocialAuthSection` that supports sign-ins via Google, GitHub, LinkedIn, and X (Twitter). +- **OAuth Session Management**: Extended `oauth-session` utilities to track the user's last-used provider via localStorage and intelligently resolve post-login redirect paths using `getLastActiveModule()`. +- **OAuth Handoff Improvements**: Refactored `TokenHandoffHandler` in `authProvider.tsx` to prevent login UI flashing during OAuth redirects, optimized the redirect logic to eliminate duplicate page reloads when the user is already on the destination route, and configured `middleware.ts` to intelligently bypass server-side routing for seamless token consumption. +- **API Proxy Routing**: Updated the Next.js API proxy destination from `staging-analytics.airqo.net` to `staging-vertex.airqo.net` across both `next.config.js` and `next.config.mjs`. +- **Legacy Route Redirects**: Added Next.js config redirects to automatically route legacy `/user/home` to `/home` and `/user/login` to `/login`. Updated `middleware.ts` to simplify the `authorized` callback. + +
+ +
+Security & Permissions (2) + +- **Admin Role Permissions Fix**: Restored access to the Sensor Manufacturers admin panel by explicitly including the `PERMISSIONS.NETWORK.VIEW` right in the static `AIRQO_ADMIN` role definition. +- **Dynamic hCaptcha Environments**: Upgraded `isHCaptchaEnabled` to conditionally support hCaptcha on production, staging, and local development environments solely based on the presence of a valid site key. + +
+ +
+Files Modified (11) + +- `.env.example` [MODIFIED] +- `app/login/page.tsx` [MODIFIED] +- `components/features/auth/social-auth-section.tsx` [ADDED] +- `components/features/auth/google-auth-section.tsx` [DELETED] +- `core/auth/authProvider.tsx` [MODIFIED] +- `core/auth/oauth-session.ts` [MODIFIED] +- `core/permissions/constants.ts` [MODIFIED] +- `lib/envConstants.ts` [MODIFIED] +- `middleware.ts` [MODIFIED] +- `next.config.mjs` [MODIFIED] +- `next.config.js` [MODIFIED] + +
+ +--- + + + ## Version 1.23.59 **Released:** June 04, 2026 diff --git a/src/vertex/app/login/page.tsx b/src/vertex/app/login/page.tsx index 0a96736ace..9f42c0dc19 100644 --- a/src/vertex/app/login/page.tsx +++ b/src/vertex/app/login/page.tsx @@ -24,6 +24,7 @@ import { import { getLastActiveModule } from "@/core/utils/userPreferences"; import { ROUTE_LINKS } from "@/core/routes"; // import GoogleAuthSection from "@/components/features/auth/google-auth-section"; +import SocialAuthSection from "@/components/features/auth/social-auth-section"; import { motion, AnimatePresence } from "framer-motion"; @@ -104,10 +105,24 @@ export default function LoginPage() { 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]); + }, [dispatch, searchParams, showBanner]); const onSubmit = useCallback(async (values: z.infer) => { @@ -244,13 +259,14 @@ export default function LoginPage() {
- {/* {step === 'email' && ( - - )} */} + )}
{ - if (typeof window === 'undefined' || disabled) return; - - setIsRedirecting(true); - - try { - window.location.replace( - buildOAuthInitiationUrl('google', { - prompt: 'select_account', - tenant: 'airqo', - callbackUrl: callbackUrl, - }) - ); - } catch (error) { - setIsRedirecting(false); - showBanner({ - severity: 'error', - message: 'Google sign-in unavailable. Please try again in a moment.', - scoped: true, - }); - console.error('Failed to start Google OAuth flow:', error); - } - }, [disabled, callbackUrl, showBanner]); - - return ( -
- - Continue with Google - - -
- - Or - -
-
- ); -} diff --git a/src/vertex/components/features/auth/social-auth-section.tsx b/src/vertex/components/features/auth/social-auth-section.tsx new file mode 100644 index 0000000000..f00f7c115c --- /dev/null +++ b/src/vertex/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/core/apis/organizations.ts b/src/vertex/core/apis/organizations.ts index 25ba18af93..1f8833a58d 100644 --- a/src/vertex/core/apis/organizations.ts +++ b/src/vertex/core/apis/organizations.ts @@ -1,10 +1,10 @@ import { CohortGroupsResponse } from "@/app/types/groups"; -import createSecureApiClient from "../utils/secureApiProxyClient"; +import { secureApiProxy } from "../utils/secureApiProxyClient"; export const groupsApi = { getGroupsApi: async () => { try { - const response = await createSecureApiClient().get(`/users/groups/summary`, { headers: { 'X-Auth-Type': 'JWT' } }); + const response = await secureApiProxy.get(`/users/groups/summary`, { headers: { 'X-Auth-Type': 'JWT' } }); return response.data; } catch (error) { throw error; @@ -12,10 +12,27 @@ export const groupsApi = { }, getGroupsByCohortApi: async (cohortId: string): Promise => { try { - const response = await createSecureApiClient().get(`/users/groups?cohort_id=${cohortId}`, { headers: { 'X-Auth-Type': 'JWT' } }); + const response = await secureApiProxy.get(`/users/groups?cohort_id=${cohortId}`, { headers: { 'X-Auth-Type': 'JWT' } }); return response.data as CohortGroupsResponse; } catch (error) { throw error; } }, + updateGroupOnboardingApi: async (groupId: string, payload: { action: 'mark_step_complete' | 'dismiss_checklist'; step_id?: string }) => { + try { + const response = await secureApiProxy.patch(`/users/groups/${groupId}/onboarding`, payload, { headers: { 'X-Auth-Type': 'JWT' } }); + return response.data; + } catch (error) { + throw error; + } + }, + getGroupDetailsApi: async (groupId: string) => { + try { + const response = await secureApiProxy.get(`/users/groups/${groupId}`, { headers: { 'X-Auth-Type': 'JWT' } }); + return response.data; + } catch (error) { + throw error; + } + } }; + diff --git a/src/vertex/core/auth/authProvider.tsx b/src/vertex/core/auth/authProvider.tsx index 74bf373ec7..66f5a39253 100644 --- a/src/vertex/core/auth/authProvider.tsx +++ b/src/vertex/core/auth/authProvider.tsx @@ -584,6 +584,10 @@ function AutoLogoutHandler() { * It detects the token, triggers signIn, and handles the redirection. */ function TokenHandoffHandler({ children }: { children: React.ReactNode }) { + // Use a ref to capture the initial hash state so we don't rely on the mutable URL hash during re-renders + const isHandlingOAuthRef = useRef( + typeof window !== 'undefined' && window.location.hash.includes('token=') + ); const [isBootstrapping, setIsBootstrapping] = useState(true); const router = useRouter(); const pathname = usePathname(); @@ -609,6 +613,7 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) { }, []); useEffect(() => { + let shouldUnblock = true; const bootstrap = async () => { try { const handoff = consumeOAuthTokenHandoffFromUrl(); @@ -632,8 +637,22 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) { const redirectUrl = handoff.callbackUrl || fallbackUrl; logger.info(`[TokenHandoffHandler] OAuth sign-in successful, redirecting to ${redirectUrl}`); - window.location.replace(redirectUrl); - return; // window.location.replace will handle the rest + + const redirectPathname = redirectUrl.split('?')[0]; + const isSamePath = pathname === redirectPathname; + + if (isSamePath) { + // We are already on the destination page (e.g., /home?success=google). + // Use client-side routing to silently clean up the URL without a full page reload. + router.replace(redirectUrl); + // Allow the UI to unblock since we aren't unloading the document + } else { + // We are on a different page (e.g., /login). + // Do a hard replace and keep the UI blocked until the page unloads to prevent flashing. + shouldUnblock = false; // Prevent unblocking the UI while the browser redirects + window.location.replace(redirectUrl); + return; // window.location.replace will handle the rest + } } else { logger.error('[TokenHandoffHandler] OAuth sign-in failed', { error: result?.error }); router.push(`/auth-error?error=${encodeURIComponent(result?.error || 'OAuthSignin')}`); @@ -642,14 +661,17 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) { } catch (error) { logger.error('[TokenHandoffHandler] Error during bootstrap', { error }); } finally { - setIsBootstrapping(false); + if (shouldUnblock) { + setIsBootstrapping(false); + isHandlingOAuthRef.current = false; + } } }; bootstrap(); - }, [router, pathname]); + }, [router, pathname, waitForSession]); - if (isBootstrapping && typeof window !== 'undefined' && window.location.hash.includes('token=')) { + if (isBootstrapping && isHandlingOAuthRef.current) { return ; } diff --git a/src/vertex/core/auth/oauth-session.ts b/src/vertex/core/auth/oauth-session.ts index b5026e8325..df070bf525 100644 --- a/src/vertex/core/auth/oauth-session.ts +++ b/src/vertex/core/auth/oauth-session.ts @@ -2,6 +2,71 @@ import { getApiBaseUrl } from "@/lib/envConstants"; const OAUTH_FRAGMENT_TOKEN_KEY = 'token'; const OAUTH_SUCCESS_PROVIDER_KEY = 'success'; +const LAST_USED_OAUTH_PROVIDER_KEY = 'vertex:last-oauth-provider'; + +export const SUPPORTED_SOCIAL_AUTH_PROVIDERS = [ + 'google', + 'github', + 'linkedin', + 'twitter', +] as const; + +export type SupportedSocialAuthProvider = + (typeof SUPPORTED_SOCIAL_AUTH_PROVIDERS)[number]; + +export const isSupportedSocialAuthProvider = ( + value: string | null | undefined +): value is SupportedSocialAuthProvider => { + if (!value) { + return false; + } + + return (SUPPORTED_SOCIAL_AUTH_PROVIDERS as readonly string[]).includes(value); +}; + +export const resolveOAuthRedirectAfterUrl = ( + targetPath = '/home' +): string | null => { + if (typeof window === 'undefined') { + return null; + } + + const normalizedTargetPath = targetPath.startsWith('/') + ? targetPath + : `/${targetPath}`; + + try { + return new URL(normalizedTargetPath, window.location.origin).toString(); + } catch { + return null; + } +}; + +export const getLastUsedOAuthProvider = + (): SupportedSocialAuthProvider | null => { + if (typeof window === 'undefined') { + return null; + } + + const provider = localStorage.getItem(LAST_USED_OAUTH_PROVIDER_KEY)?.trim(); + + return isSupportedSocialAuthProvider(provider) ? provider : null; + }; + +export const setLastUsedOAuthProvider = ( + provider: string | null | undefined +): void => { + if (typeof window === 'undefined') { + return; + } + + if (!isSupportedSocialAuthProvider(provider)) { + localStorage.removeItem(LAST_USED_OAUTH_PROVIDER_KEY); + return; + } + + localStorage.setItem(LAST_USED_OAUTH_PROVIDER_KEY, provider); +}; export interface OAuthTokenHandoff { token: string; diff --git a/src/vertex/core/hooks/useGroups.ts b/src/vertex/core/hooks/useGroups.ts index f0a603a006..ac56c05649 100644 --- a/src/vertex/core/hooks/useGroups.ts +++ b/src/vertex/core/hooks/useGroups.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { groupsApi } from "../apis/organizations"; import { setError, setGroups } from "../redux/slices/groupsSlice"; import { useDispatch } from "react-redux"; @@ -43,3 +43,19 @@ export const useGroupsByCohort = (cohortId: string) => { }; }; +export const useGroupDetails = (groupId: string, options?: any) => { + return useQuery({ + queryKey: ["groupDetails", groupId], + queryFn: () => groupsApi.getGroupDetailsApi(groupId), + enabled: !!groupId, + ...options, + }); +}; + +export const useUpdateGroupOnboarding = () => { + return useMutation({ + mutationFn: ({ groupId, payload }: { groupId: string, payload: { action: 'mark_step_complete' | 'dismiss_checklist'; step_id?: string } }) => + groupsApi.updateGroupOnboardingApi(groupId, payload), + }); +}; + diff --git a/src/vertex/core/permissions/constants.ts b/src/vertex/core/permissions/constants.ts index 9cfa3af84b..72616c3807 100644 --- a/src/vertex/core/permissions/constants.ts +++ b/src/vertex/core/permissions/constants.ts @@ -132,6 +132,7 @@ export const ROLES = { ...Object.values(PERMISSIONS.SITE), ...Object.values(PERMISSIONS.ANALYTICS), ...Object.values(PERMISSIONS.SETTINGS), + PERMISSIONS.NETWORK.VIEW, ], canOverrideOrganization: false, systemWide: true, diff --git a/src/vertex/core/redux/slices/userSlice.ts b/src/vertex/core/redux/slices/userSlice.ts index 14668cc584..e016e91a2d 100644 --- a/src/vertex/core/redux/slices/userSlice.ts +++ b/src/vertex/core/redux/slices/userSlice.ts @@ -207,6 +207,17 @@ const userSlice = createSlice({ state.userContext = context; state.canSwitchContext = canSwitchContext; }, + updateActiveGroupOnboarding(state, action: PayloadAction) { + if (state.activeGroup) { + state.activeGroup.onboarding_checklist = action.payload; + } + if (state.activeGroup?._id) { + const groupIndex = state.userGroups.findIndex(g => g._id === state.activeGroup?._id); + if (groupIndex !== -1) { + state.userGroups[groupIndex].onboarding_checklist = action.payload; + } + } + }, setUserGroups(state, action: PayloadAction) { const groups = action.payload || []; state.userGroups = groups; @@ -305,6 +316,7 @@ export const { setAvailableNetworks, setInitialized, setActiveGroup, + updateActiveGroupOnboarding, setUserGroups, setUserContext, setForbiddenState, diff --git a/src/vertex/lib/envConstants.ts b/src/vertex/lib/envConstants.ts index 2d69659a95..5ff4c51e8a 100644 --- a/src/vertex/lib/envConstants.ts +++ b/src/vertex/lib/envConstants.ts @@ -119,10 +119,12 @@ export const getEnvironment = (): string => { /** * Whether hCaptcha should be enabled for this deployment. * - * Requirement: only available on staging (never production). + * Requirement: available on staging and production as long as the hCaptcha site key is set. */ export const isHCaptchaEnabled = (): boolean => { - return getEnvironment().toLowerCase() === 'staging'; + const env = getEnvironment().toLowerCase(); + const hasSiteKey = Boolean(getHCaptchaSiteKey()); + return (env === 'development' || env === 'staging' || env === 'production') && hasSiteKey; }; /** diff --git a/src/vertex/middleware.ts b/src/vertex/middleware.ts index 08dae96c51..4790e9ee89 100644 --- a/src/vertex/middleware.ts +++ b/src/vertex/middleware.ts @@ -14,7 +14,14 @@ export default withAuth( }, { callbacks: { - authorized: ({ token }) => !!token, + authorized: ({ req, token }) => { + // Bypass server-side protection for OAuth callbacks so the client can process the token hash. + // Client-side AuthWrapper will still enforce protection if the token is invalid. + if (req.nextUrl.searchParams.has('success')) { + return true; + } + return Boolean(token); + }, }, pages: { signIn: "/login", diff --git a/src/vertex/next.config.js b/src/vertex/next.config.js index 1f840fdf43..3586e5ba05 100644 --- a/src/vertex/next.config.js +++ b/src/vertex/next.config.js @@ -5,13 +5,28 @@ const nextConfig = { return [ { source: '/api/v2/:path*', - destination: 'https://staging-analytics.airqo.net/api/v2/:path*', + destination: 'https://staging-vertex.airqo.net/api/v2/:path*', }, ]; } return []; }, + async redirects() { + return [ + { + source: '/user/login', + destination: '/login', + permanent: false, + }, + { + source: '/user/home', + destination: '/home', + permanent: false, + }, + ]; + }, + async headers() { return [ { diff --git a/src/vertex/next.config.mjs b/src/vertex/next.config.mjs index ff06872bd4..e8e560c366 100644 --- a/src/vertex/next.config.mjs +++ b/src/vertex/next.config.mjs @@ -11,13 +11,28 @@ const nextConfig = { return [ { source: '/api/v2/:path*', - destination: 'https://staging-analytics.airqo.net/api/v2/:path*', + destination: 'https://staging-vertex.airqo.net/api/v2/:path*', }, ]; } return []; }, + async redirects() { + return [ + { + source: '/user/login', + destination: '/login', + permanent: false, + }, + { + source: '/user/home', + destination: '/home', + permanent: false, + }, + ]; + }, + async headers() { return [ {