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 [ {