From 1ff2486764720819181904286b730cfc77fa14e2 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Wed, 3 Jun 2026 17:53:00 +0300 Subject: [PATCH 01/23] Enable hCaptcha for prod/staging when site key set Change isHCaptchaEnabled to allow hCaptcha on production as well as staging, but only if a hCaptcha site key is configured. The function now checks getEnvironment() (lowercased) and requires getHCaptchaSiteKey() to be truthy; the comment was updated to match the new behavior. --- src/vertex/lib/envConstants.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vertex/lib/envConstants.ts b/src/vertex/lib/envConstants.ts index 2d69659a95..9554934c68 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 === 'staging' || env === 'production') && hasSiteKey; }; /** From edfa4918d83dd9afcac6aa5f45107848f8ee8bfb Mon Sep 17 00:00:00 2001 From: Codebmk Date: Wed, 3 Jun 2026 18:31:28 +0300 Subject: [PATCH 02/23] Add social auth section and OAuth helpers Replace the single-provider Google auth component with a new SocialAuthSection that supports Google, GitHub, LinkedIn and X. Extend oauth-session with supported provider types, helpers to resolve redirect URLs, and localStorage helpers to track the last-used provider. Update the login page to use the new social component, display an error banner for oauth failures, and clean the URL after showing the error. Add a redirect from /user/login to /login in next.config and update NEXT_PUBLIC_API_URL in the example env to the vertex staging API. Remove the old google-auth-section component. --- src/vertex/.env.example | 2 +- src/vertex/app/login/page.tsx | 21 ++- .../features/auth/google-auth-section.tsx | 71 -------- .../features/auth/social-auth-section.tsx | 155 ++++++++++++++++++ src/vertex/core/auth/oauth-session.ts | 65 ++++++++ src/vertex/next.config.mjs | 10 ++ 6 files changed, 248 insertions(+), 76 deletions(-) delete mode 100644 src/vertex/components/features/auth/google-auth-section.tsx create mode 100644 src/vertex/components/features/auth/social-auth-section.tsx 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/login/page.tsx b/src/vertex/app/login/page.tsx index 0a96736ace..f6301bf949 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,21 @@ 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, + }); + // Clean up the URL to prevent showing the error on refresh + window.history.replaceState({}, '', '/login'); + } + return () => { isMounted.current = false; }; - }, [dispatch]); + }, [dispatch, searchParams, showBanner]); const onSubmit = useCallback(async (values: z.infer) => { @@ -244,13 +256,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..2466298595 --- /dev/null +++ b/src/vertex/components/features/auth/social-auth-section.tsx @@ -0,0 +1,155 @@ +'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, + type SupportedSocialAuthProvider, +} from '@/core/auth/oauth-session'; +import { cn } from '@/lib/utils'; +import { useBanner } from '@/context/banner-context'; + +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 redirectPath = callbackUrl || '/home'; + 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 { + 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/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/next.config.mjs b/src/vertex/next.config.mjs index ff06872bd4..d52479b9be 100644 --- a/src/vertex/next.config.mjs +++ b/src/vertex/next.config.mjs @@ -18,6 +18,16 @@ const nextConfig = { return []; }, + async redirects() { + return [ + { + source: '/user/login', + destination: '/login', + permanent: false, + }, + ]; + }, + async headers() { return [ { From 762208b58413efe31ffd932b172757ca58900839 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 4 Jun 2026 05:07:42 +0300 Subject: [PATCH 03/23] Update auth redirects, middleware, and API proxy Use user's last active module for social auth redirect (falls back to /admin/networks for admin or /home) and import getLastActiveModule in SocialAuthSection. Change middleware authorized callback to always return true. Update API proxy destination from staging-analytics.airqo.net to staging-vertex.airqo.net in both next.config files and add a redirect from /user/home to /home in next.config.mjs. --- .../components/features/auth/social-auth-section.tsx | 5 ++++- src/vertex/middleware.ts | 2 +- src/vertex/next.config.js | 2 +- src/vertex/next.config.mjs | 7 ++++++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/vertex/components/features/auth/social-auth-section.tsx b/src/vertex/components/features/auth/social-auth-section.tsx index 2466298595..a5a248c6b4 100644 --- a/src/vertex/components/features/auth/social-auth-section.tsx +++ b/src/vertex/components/features/auth/social-auth-section.tsx @@ -12,6 +12,7 @@ import { } 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'; @@ -59,7 +60,9 @@ export default function SocialAuthSection({ }: SocialAuthSectionProps) { const { showBanner } = useBanner(); const actionLabel = mode === 'register' ? 'Continue with' : 'Sign in with'; - const redirectPath = callbackUrl || '/home'; + const lastModule = getLastActiveModule(); + const fallbackUrl = lastModule === 'admin' ? '/admin/networks' : '/home'; + const redirectPath = callbackUrl || fallbackUrl; const [lastUsedProvider, setLastUsedProvider] = useState(null); diff --git a/src/vertex/middleware.ts b/src/vertex/middleware.ts index 08dae96c51..a25a149537 100644 --- a/src/vertex/middleware.ts +++ b/src/vertex/middleware.ts @@ -14,7 +14,7 @@ export default withAuth( }, { callbacks: { - authorized: ({ token }) => !!token, + authorized: () => true, }, pages: { signIn: "/login", diff --git a/src/vertex/next.config.js b/src/vertex/next.config.js index 1f840fdf43..5b72ceeaf6 100644 --- a/src/vertex/next.config.js +++ b/src/vertex/next.config.js @@ -5,7 +5,7 @@ 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*', }, ]; } diff --git a/src/vertex/next.config.mjs b/src/vertex/next.config.mjs index d52479b9be..e8e560c366 100644 --- a/src/vertex/next.config.mjs +++ b/src/vertex/next.config.mjs @@ -11,7 +11,7 @@ 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*', }, ]; } @@ -25,6 +25,11 @@ const nextConfig = { destination: '/login', permanent: false, }, + { + source: '/user/home', + destination: '/home', + permanent: false, + }, ]; }, From 7510be3c094b80eab6bff84eaec4ff45ba1eafc0 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 4 Jun 2026 05:17:06 +0300 Subject: [PATCH 04/23] Enable hCaptcha in development Extend isHCaptchaEnabled to also return true in development when an hCaptcha site key is present. Previously hCaptcha was only enabled for staging and production, which prevented local/dev testing; this change allows developers to test hCaptcha locally if the site key is configured. --- src/vertex/lib/envConstants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vertex/lib/envConstants.ts b/src/vertex/lib/envConstants.ts index 9554934c68..5ff4c51e8a 100644 --- a/src/vertex/lib/envConstants.ts +++ b/src/vertex/lib/envConstants.ts @@ -124,7 +124,7 @@ export const getEnvironment = (): string => { export const isHCaptchaEnabled = (): boolean => { const env = getEnvironment().toLowerCase(); const hasSiteKey = Boolean(getHCaptchaSiteKey()); - return (env === 'staging' || env === 'production') && hasSiteKey; + return (env === 'development' || env === 'staging' || env === 'production') && hasSiteKey; }; /** From 95085851a8b21bf506aa9ccd7b78f3924736d02d Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 4 Jun 2026 05:32:36 +0300 Subject: [PATCH 05/23] Add NETWORK.VIEW to role permissions Include PERMISSIONS.NETWORK.VIEW in the role's permission array in src/vertex/core/permissions/constants.ts so the system-wide role (canOverrideOrganization: false) is granted network view access. --- src/vertex/core/permissions/constants.ts | 1 + 1 file changed, 1 insertion(+) 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, From 39c98992c9732951edff58af81f69af15bb88832 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 4 Jun 2026 05:46:23 +0300 Subject: [PATCH 06/23] Update changelog.md --- src/vertex/app/changelog.md | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 298969e18c..65d1c88e4a 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -4,6 +4,48 @@ --- +## Version 1.23.59 +**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()`. +- **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 (9) + +- `src/vertex/.env.example` [MODIFIED] +- `src/vertex/app/login/page.tsx` [MODIFIED] +- `src/vertex/components/features/auth/social-auth-section.tsx` [ADDED] +- `src/vertex/components/features/auth/google-auth-section.tsx` [DELETED] +- `src/vertex/core/auth/oauth-session.ts` [MODIFIED] +- `src/vertex/core/permissions/constants.ts` [MODIFIED] +- `src/vertex/lib/envConstants.ts` [MODIFIED] +- `src/vertex/middleware.ts` [MODIFIED] +- `src/vertex/next.config.mjs` (and `.js`) [MODIFIED] + +
+ +--- + ## Version 1.23.58 **Released:** June 03, 2026 From 0de31e7d47c0279730e86975400f127504ced05c Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 4 Jun 2026 06:34:00 +0300 Subject: [PATCH 07/23] Correct file paths to follow vertex changelog convention. --- src/vertex/app/changelog.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 65d1c88e4a..dfdb779fdd 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -30,17 +30,18 @@ Introduced comprehensive multi-provider social authentication, updated API proxy
-Files Modified (9) +Files Modified (10) -- `src/vertex/.env.example` [MODIFIED] -- `src/vertex/app/login/page.tsx` [MODIFIED] -- `src/vertex/components/features/auth/social-auth-section.tsx` [ADDED] -- `src/vertex/components/features/auth/google-auth-section.tsx` [DELETED] -- `src/vertex/core/auth/oauth-session.ts` [MODIFIED] -- `src/vertex/core/permissions/constants.ts` [MODIFIED] -- `src/vertex/lib/envConstants.ts` [MODIFIED] -- `src/vertex/middleware.ts` [MODIFIED] -- `src/vertex/next.config.mjs` (and `.js`) [MODIFIED] +- `.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/oauth-session.ts` [MODIFIED] +- `core/permissions/constants.ts` [MODIFIED] +- `lib/envConstants.ts` [MODIFIED] +- `middleware.ts` [MODIFIED] +- `next.config.mjs` [MODIFIED] +- `next.config.js` [MODIFIED]
From ccbad26d8478544bd08877d73a06eb2a37ed6894 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 08:36:11 +0300 Subject: [PATCH 08/23] Update changelog.md --- src/vertex/app/changelog.md | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 27e81b9064..0a35a5e480 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -4,6 +4,50 @@ --- +## Version 1.23.59 +**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()`. +- **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 (9) + +- `src/vertex/.env.example` [MODIFIED] +- `src/vertex/app/login/page.tsx` [MODIFIED] +- `src/vertex/components/features/auth/social-auth-section.tsx` [ADDED] +- `src/vertex/components/features/auth/google-auth-section.tsx` [DELETED] +- `src/vertex/core/auth/oauth-session.ts` [MODIFIED] +- `src/vertex/core/permissions/constants.ts` [MODIFIED] +- `src/vertex/lib/envConstants.ts` [MODIFIED] +- `src/vertex/middleware.ts` [MODIFIED] +- `src/vertex/next.config.mjs` (and `.js`) [MODIFIED] + +
+ +--- + + + ## Version 1.23.59 **Released:** June 04, 2026 From 7a886a48672ffad55187fcb149ba82dd685eed5d Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 08:38:24 +0300 Subject: [PATCH 09/23] Update changelog.md --- src/vertex/app/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 0a35a5e480..4550d2dec3 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -4,7 +4,7 @@ --- -## Version 1.23.59 +## Version 1.23.60 **Released:** June 04, 2026 ### Multi-Provider Social Auth, API Proxy & Role Permissions From 1ce578ecca49254035056db5b90dd480cf071b5e Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 08:41:04 +0300 Subject: [PATCH 10/23] Correct file paths to follow vertex changelog convention --- src/vertex/app/changelog.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 4550d2dec3..7325711f19 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -30,17 +30,18 @@ Introduced comprehensive multi-provider social authentication, updated API proxy
-Files Modified (9) +Files Modified (10) -- `src/vertex/.env.example` [MODIFIED] -- `src/vertex/app/login/page.tsx` [MODIFIED] -- `src/vertex/components/features/auth/social-auth-section.tsx` [ADDED] -- `src/vertex/components/features/auth/google-auth-section.tsx` [DELETED] -- `src/vertex/core/auth/oauth-session.ts` [MODIFIED] -- `src/vertex/core/permissions/constants.ts` [MODIFIED] -- `src/vertex/lib/envConstants.ts` [MODIFIED] -- `src/vertex/middleware.ts` [MODIFIED] -- `src/vertex/next.config.mjs` (and `.js`) [MODIFIED] +- `.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/oauth-session.ts` [MODIFIED] +- `core/permissions/constants.ts` [MODIFIED] +- `lib/envConstants.ts` [MODIFIED] +- `middleware.ts` [MODIFIED] +- `next.config.mjs` [MODIFIED] +- `next.config.js` [MODIFIED]
From 7012b61c622df5b0cd94559ae05be5e6b438fec6 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 08:43:14 +0300 Subject: [PATCH 11/23] OAuth failure banner may not be visible on the email step --- src/vertex/app/login/page.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vertex/app/login/page.tsx b/src/vertex/app/login/page.tsx index f6301bf949..9f42c0dc19 100644 --- a/src/vertex/app/login/page.tsx +++ b/src/vertex/app/login/page.tsx @@ -112,8 +112,11 @@ export default function LoginPage() { message: 'Social sign-in failed or was cancelled. Please try again.', scoped: true, }); - // Clean up the URL to prevent showing the error on refresh - window.history.replaceState({}, '', '/login'); + // 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 () => { From 02e3d6dce1ec0ab8c3d8797b25de2214dd434043 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 08:49:31 +0300 Subject: [PATCH 12/23] Persist the selected provider before redirecting OAuth. --- src/vertex/components/features/auth/social-auth-section.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vertex/components/features/auth/social-auth-section.tsx b/src/vertex/components/features/auth/social-auth-section.tsx index a5a248c6b4..f00f7c115c 100644 --- a/src/vertex/components/features/auth/social-auth-section.tsx +++ b/src/vertex/components/features/auth/social-auth-section.tsx @@ -8,6 +8,7 @@ import { buildOAuthInitiationUrl, getLastUsedOAuthProvider, resolveOAuthRedirectAfterUrl, + setLastUsedOAuthProvider, type SupportedSocialAuthProvider, } from '@/core/auth/oauth-session'; import { cn } from '@/lib/utils'; @@ -94,6 +95,7 @@ export default function SocialAuthSection({ try { + setLastUsedOAuthProvider(provider); window.location.replace(buildOAuthInitiationUrl(provider, queryParams)); } catch (error) { showBanner({ From 70799d6f98b3c2d5e72bd7734925f9ab94a52790 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 08:51:50 +0300 Subject: [PATCH 13/23] Fix NextAuth auth gating in src/vertex/middleware.ts --- src/vertex/middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vertex/middleware.ts b/src/vertex/middleware.ts index a25a149537..ef753c21f4 100644 --- a/src/vertex/middleware.ts +++ b/src/vertex/middleware.ts @@ -14,7 +14,7 @@ export default withAuth( }, { callbacks: { - authorized: () => true, + authorized: ({ token }) => Boolean(token), }, pages: { signIn: "/login", From 8d5f5c925630fc08ddd2c07b5baba31392c7a68a Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 08:52:56 +0300 Subject: [PATCH 14/23] Consolidate/mirror legacy redirects across Vertex Next.js configs --- src/vertex/next.config.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/vertex/next.config.js b/src/vertex/next.config.js index 5b72ceeaf6..3586e5ba05 100644 --- a/src/vertex/next.config.js +++ b/src/vertex/next.config.js @@ -12,6 +12,21 @@ const nextConfig = { return []; }, + async redirects() { + return [ + { + source: '/user/login', + destination: '/login', + permanent: false, + }, + { + source: '/user/home', + destination: '/home', + permanent: false, + }, + ]; + }, + async headers() { return [ { From b0612a7fbdc6274721bf032f29d7d1732648adca Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 09:40:30 +0300 Subject: [PATCH 15/23] Handle OAuth token handoff and middleware bypass Capture the initial OAuth hash and prevent UI unblocking during client-side token handoff: use a ref (isHandlingOAuthRef) to record if the URL initially contains a token, add a shouldUnblock flag so setIsBootstrapping(false) is skipped when the browser is redirected, and only clear the ref when safe. Also add waitForSession to the effect deps and use the ref for the initial loading guard. In middleware, relax server-side authorization for OAuth callback requests (allow when search param 'success' is present) so the client can process the token hash while still enforcing protection client-side if the token is invalid. --- src/vertex/core/auth/authProvider.tsx | 15 ++++++++++++--- src/vertex/middleware.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/vertex/core/auth/authProvider.tsx b/src/vertex/core/auth/authProvider.tsx index 74bf373ec7..a0226af7d6 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,6 +637,7 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) { const redirectUrl = handoff.callbackUrl || fallbackUrl; logger.info(`[TokenHandoffHandler] OAuth sign-in successful, redirecting to ${redirectUrl}`); + shouldUnblock = false; // Prevent unblocking the UI while the browser redirects window.location.replace(redirectUrl); return; // window.location.replace will handle the rest } else { @@ -642,14 +648,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/middleware.ts b/src/vertex/middleware.ts index ef753c21f4..4790e9ee89 100644 --- a/src/vertex/middleware.ts +++ b/src/vertex/middleware.ts @@ -14,7 +14,14 @@ export default withAuth( }, { callbacks: { - authorized: ({ token }) => Boolean(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", From 7eda50db188ea1ade55eb055bc05fffe76aef2b8 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 09:40:43 +0300 Subject: [PATCH 16/23] Update changelog.md --- src/vertex/app/changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 7325711f19..87bd7e66ef 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -16,6 +16,7 @@ Introduced comprehensive multi-provider social authentication, updated API proxy - **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, 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. @@ -36,6 +37,7 @@ Introduced comprehensive multi-provider social authentication, updated API proxy - `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] From 9eb0f127f08b904e5235c68d45f3709cd0c6aa6d Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 09:56:01 +0300 Subject: [PATCH 17/23] Improve OAuth redirect handling in authProvider Refine TokenHandoffHandler to avoid unnecessary full-page reloads when the client is already on the redirect destination. The handler now compares the current pathname to the redirect target and uses client-side router.replace for same-path redirects (allowing the UI to unblock), while preserving window.location.replace for cross-page redirects and keeping the UI blocked to prevent flashing. Also update changelog to mention the optimized redirect logic. --- src/vertex/app/changelog.md | 2 +- src/vertex/core/auth/authProvider.tsx | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 87bd7e66ef..a76e2dbad9 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -16,7 +16,7 @@ Introduced comprehensive multi-provider social authentication, updated API proxy - **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, and configured `middleware.ts` to intelligently bypass server-side routing for seamless token consumption. +- **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. diff --git a/src/vertex/core/auth/authProvider.tsx b/src/vertex/core/auth/authProvider.tsx index a0226af7d6..66f5a39253 100644 --- a/src/vertex/core/auth/authProvider.tsx +++ b/src/vertex/core/auth/authProvider.tsx @@ -637,9 +637,22 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) { const redirectUrl = handoff.callbackUrl || fallbackUrl; logger.info(`[TokenHandoffHandler] OAuth sign-in successful, redirecting to ${redirectUrl}`); - shouldUnblock = false; // Prevent unblocking the UI while the browser redirects - 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')}`); From cfd100384be4402ef4e24f9eb436ec9da28a005c Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 10:20:36 +0300 Subject: [PATCH 18/23] Update changelog.md --- src/vertex/app/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index a76e2dbad9..35c33b2968 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -31,7 +31,7 @@ Introduced comprehensive multi-provider social authentication, updated API proxy
-Files Modified (10) +Files Modified (11) - `.env.example` [MODIFIED] - `app/login/page.tsx` [MODIFIED] From 47acf765cec9aee04390fefdc94671208c841735 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 11:22:35 +0300 Subject: [PATCH 19/23] Integrate group onboarding checklist sync Fetch and sync organization onboarding checklist state from the server and persist updates. - Add API helpers: updateGroupOnboardingApi and getGroupDetailsApi to organizations API. - Update types: add onboarding_checklist to Group and User types. - Extend userSlice with updateActiveGroupOnboarding to update active group and userGroups state. - Revamp WelcomePage: fetch up-to-date group details, separate local (personal) and org checklist state, compute visuallyCompletedSteps, and handle marking/dismissing steps via API calls. Updates also update query cache and dispatch redux updates. Auto-complete logic for steps now accounts for org vs personal scope. - Misc: import/use useAppDispatch and react-query useQuery for group details. This enables backend-driven onboarding checklist for org workspaces while preserving local storage behavior for personal scope. --- src/vertex/app/(authenticated)/home/page.tsx | 190 +++++++++++++------ src/vertex/app/types/groups.ts | 4 + src/vertex/app/types/users.ts | 4 + src/vertex/core/apis/organizations.ts | 17 ++ src/vertex/core/redux/slices/userSlice.ts | 12 ++ 5 files changed, 173 insertions(+), 54 deletions(-) diff --git a/src/vertex/app/(authenticated)/home/page.tsx b/src/vertex/app/(authenticated)/home/page.tsx index d17da88564..cc7e1e66e8 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"; @@ -13,13 +13,15 @@ import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; import { Skeleton } from "@/components/ui/skeleton"; import { useDevices, useMyDevices } from "@/core/hooks/useDevices"; import { useGroupCohorts, usePersonalUserCohorts } from "@/core/hooks/useCohorts"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQueryClient, useQuery } from "@tanstack/react-query"; import ContextHeader from "@/components/features/home/context-header"; import NetworkVisibilityCard from "@/components/features/home/network-visibility-card"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import OnboardingChecklist from "@/components/features/home/onboarding-checklist"; import { cn } from "@/lib/utils"; import { Device } from "@/app/types/devices"; +import { updateGroupOnboardingApi, getGroupDetailsApi } from "@/core/apis/organizations"; +import { updateActiveGroupOnboarding } from "@/core/redux/slices/userSlice"; import { formatTitle } from "@/components/features/org-picker/organization-picker"; import ReusableToast from "@/components/shared/toast/ReusableToast"; @@ -131,6 +133,7 @@ const WelcomePage = () => { const [highlightVisibility, setHighlightVisibility] = React.useState(false); const visibilityRef = React.useRef(null); const queryClient = useQueryClient(); + const dispatch = useAppDispatch(); const user = useAppSelector((state) => state.user.userDetails); const userId = (session?.user as { id?: string })?.id || user?._id; @@ -144,34 +147,105 @@ const WelcomePage = () => { ? `org_${activeGroup._id}` : null; - // ── Checklist state (frontend-only, localStorage-backed) ────────────────── - const [checklistState, setChecklistState] = React.useState(() => + // Fetch up-to-date group details to ensure onboarding checklist is populated + const { data: groupDetailsData, isLoading: isLoadingGroupDetails } = useQuery({ + queryKey: ['groupDetails', activeGroup?._id], + queryFn: () => getGroupDetailsApi(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); - return next; - }); + 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) { + await Promise.allSettled( + missingAutoSteps.map(step => + updateGroupOnboardingApi(activeGroup._id, { action: 'mark_step_complete', step_id: step }) + ) + ); + } + + const res = await updateGroupOnboardingApi(activeGroup._id, { action: patch.action, step_id: patch.step_id }); + if (res.success && res.data?.onboarding_checklist) { + dispatch(updateActiveGroupOnboarding(res.data.onboarding_checklist)); + queryClient.setQueryData(['groupDetails', activeGroup._id], (old: any) => { + if (!old || !old.group) return old; + return { + ...old, + group: { + ...old.group, + onboarding_checklist: res.data.onboarding_checklist, + } + }; + }); + } + } catch (error) { + console.error("Failed to update onboarding checklist:", error); + } + } + } }, - [orgId] + [orgId, userScope, activeGroup?._id, dispatch] ); 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,32 +289,32 @@ 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]); + }, [activeChecklistState.completedSteps, updateChecklist]); // ── Permissions ──────────────────────────────────────────────────────────── const permissionsToCheck = [PERMISSIONS.DEVICE.UPDATE]; @@ -259,7 +333,6 @@ const WelcomePage = () => { { enabled: !!userId && userScope === "personal" } ); - // Cohort data — used to auto-detect step 2 completion const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, { enabled: userScope === "organisation" && !!activeGroup?._id, }); @@ -270,9 +343,9 @@ const WelcomePage = () => { // ── 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 autoSteps = React.useMemo(() => { + if (!orgId) return []; + const hasDevices = userScope === "personal" ? (myDevicesData?.devices ?? []).length > 0 @@ -283,24 +356,33 @@ const WelcomePage = () => { ? (personalCohortIds ?? []).length > 0 : (groupCohortIds ?? []).length > 0; - const autoSteps: string[] = []; + const steps: string[] = []; if (hasDevices) { - autoSteps.push("add-device", "assign-cohort"); + steps.push("add-device", "assign-cohort"); } else if (hasCohorts) { - autoSteps.push("assign-cohort"); + 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; - 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]); + 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]); // ── Early returns ────────────────────────────────────────────────────────── @@ -327,8 +409,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 +424,6 @@ const WelcomePage = () => { const renderSharedModals = () => ( <> - {/* Always rendered — controlled by open prop so sibling positions stay stable */} { @@ -444,8 +526,6 @@ const WelcomePage = () => { ); } - - const renderMainContent = () => { if (hasNoDevices) { return ( @@ -453,8 +533,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 +553,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 +625,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, }); diff --git a/src/vertex/app/types/groups.ts b/src/vertex/app/types/groups.ts index e84e0a7715..25516da195 100644 --- a/src/vertex/app/types/groups.ts +++ b/src/vertex/app/types/groups.ts @@ -16,6 +16,10 @@ export interface Group { grp_manager: UserDetails cohorts?: string[] organization_slug?: string + onboarding_checklist?: { + is_dismissed: boolean; + completed_steps: string[]; + } } export interface CohortGroupsResponse { diff --git a/src/vertex/app/types/users.ts b/src/vertex/app/types/users.ts index 2921eb6dcd..6a49064b22 100644 --- a/src/vertex/app/types/users.ts +++ b/src/vertex/app/types/users.ts @@ -39,6 +39,10 @@ export interface Group { status: string; role: Role; userType: string; + onboarding_checklist?: { + is_dismissed: boolean; + completed_steps: string[]; + }; } export interface UserDetails { diff --git a/src/vertex/core/apis/organizations.ts b/src/vertex/core/apis/organizations.ts index 25ba18af93..f3aaf15c3d 100644 --- a/src/vertex/core/apis/organizations.ts +++ b/src/vertex/core/apis/organizations.ts @@ -18,4 +18,21 @@ export const groupsApi = { throw error; } }, + updateGroupOnboardingApi: async (groupId: string, payload: { action: 'mark_step_complete' | 'dismiss_checklist'; step_id?: string }) => { + try { + const response = await createSecureApiClient().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 createSecureApiClient().get(`/users/groups/${groupId}`, { headers: { 'X-Auth-Type': 'JWT' } }); + return response.data; + } catch (error) { + throw error; + } + } }; + 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, From 86ef263b2456db6393735eb81102f5e9ae37bd5b Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 12:20:47 +0300 Subject: [PATCH 20/23] Use secureApiProxy and auto-sync onboarding steps Replace createSecureApiClient with secureApiProxy in groupsApi and export a groupsApi object for callers. In the Welcome page, switch to importing groupsApi and destructure updateGroupOnboardingApi/getGroupDetailsApi. Add permission, device and cohort hooks to auto-detect existing devices/cohorts and surface those steps as completed (visually and persisted for personal scopes). Change auto-step syncing to sequential calls with error handling, unify how updated onboarding_checklist is read from API responses, and update the react-query cache accordingly. Remove duplicated logic, update effect dependencies, and add a delayed dismiss of the checklist after workspace setup completes. --- src/vertex/app/(authenticated)/home/page.tsx | 167 ++++++++++--------- src/vertex/core/apis/organizations.ts | 10 +- 2 files changed, 96 insertions(+), 81 deletions(-) diff --git a/src/vertex/app/(authenticated)/home/page.tsx b/src/vertex/app/(authenticated)/home/page.tsx index cc7e1e66e8..a51def8f22 100644 --- a/src/vertex/app/(authenticated)/home/page.tsx +++ b/src/vertex/app/(authenticated)/home/page.tsx @@ -20,10 +20,14 @@ 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 { updateGroupOnboardingApi, getGroupDetailsApi } from "@/core/apis/organizations"; +import { groupsApi } from "@/core/apis/organizations"; + +const { updateGroupOnboardingApi, getGroupDetailsApi } = groupsApi; 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. @@ -178,6 +182,75 @@ const WelcomePage = () => { return localChecklistState; }, [userScope, activeGroup?.onboarding_checklist, groupDetails?.onboarding_checklist, localChecklistState]); + // ── Permissions ──────────────────────────────────────────────────────────── + const permissionsToCheck = [PERMISSIONS.DEVICE.UPDATE]; + const permissionsMap = usePermissions(permissionsToCheck); + const canClaimDevice = permissionsMap[PERMISSIONS.DEVICE.UPDATE]; + + // ── Device data ──────────────────────────────────────────────────────────── + const { devices: groupDevices, isLoading: isLoadingGroupDevices } = useDevices({ + limit: 1, + enabled: userScope === "organisation", + }); + + const { data: myDevicesData, isLoading: isLoadingMyDevices } = useMyDevices( + userId || "", + undefined, + { enabled: !!userId && userScope === "personal" } + ); + + // Cohort data — used to auto-detect step 2 completion + const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, { + enabled: userScope === "organisation" && !!activeGroup?._id, + }); + const { data: personalCohortIds } = usePersonalUserCohorts(userId, { + enabled: !!userId && userScope === "personal", + }); + + // ── Auto-sync step completion from real data ───────────────────────────── + // This ensures that if an org already has devices/cohorts when a user first + // logs in, steps 1 and 2 are immediately shown as complete. + const autoSteps = React.useMemo(() => { + if (!orgId) return []; + + const hasDevices = + userScope === "personal" + ? (myDevicesData?.devices ?? []).length > 0 + : groupDevices.length > 0; + + const hasCohorts = + userScope === "personal" + ? (personalCohortIds ?? []).length > 0 + : (groupCohortIds ?? []).length > 0; + + const steps: string[] = []; + if (hasDevices) { + steps.push("add-device", "assign-cohort"); + } else if (hasCohorts) { + steps.push("assign-cohort"); + } + return steps; + }, [orgId, userScope, myDevicesData?.devices, groupDevices.length, personalCohortIds, groupCohortIds]); + + const visuallyCompletedSteps = React.useMemo(() => { + return Array.from(new Set([...(activeChecklistState.completedSteps || []), ...autoSteps])); + }, [activeChecklistState.completedSteps, autoSteps]); + + React.useEffect(() => { + if (autoSteps.length === 0) return; + + if (userScope === "personal") { + setLocalChecklistState((prev) => { + const merged = Array.from(new Set([...(prev.completedSteps || []), ...autoSteps])); + // Only save/re-render if something actually changed + if (merged.length === (prev.completedSteps || []).length) return prev; + const next = { ...prev, completedSteps: merged }; + saveChecklistState(orgId as string, next); + return next; + }); + } + }, [orgId, userScope, autoSteps]); + const updateChecklist = React.useCallback( async (patch: { action?: 'mark_step_complete' | 'dismiss_checklist', step_id?: string, completedSteps?: string[], dismissed?: boolean }) => { if (userScope === "personal") { @@ -212,34 +285,38 @@ const WelcomePage = () => { ); if (missingAutoSteps.length > 0) { - await Promise.allSettled( - missingAutoSteps.map(step => - updateGroupOnboardingApi(activeGroup._id, { action: 'mark_step_complete', step_id: step }) - ) - ); + for (const step of missingAutoSteps) { + try { + await updateGroupOnboardingApi(activeGroup._id, { action: 'mark_step_complete', step_id: step }); + } catch (e) { + logger.error("Failed to sync auto-step:", { error: getApiErrorMessage(e) }); + } + } } const res = await updateGroupOnboardingApi(activeGroup._id, { action: patch.action, step_id: patch.step_id }); - if (res.success && res.data?.onboarding_checklist) { - dispatch(updateActiveGroupOnboarding(res.data.onboarding_checklist)); + const updatedChecklist = res.data?.onboarding_checklist || res.group?.onboarding_checklist; + + if (res.success && updatedChecklist) { + dispatch(updateActiveGroupOnboarding(updatedChecklist)); queryClient.setQueryData(['groupDetails', activeGroup._id], (old: any) => { if (!old || !old.group) return old; return { ...old, group: { ...old.group, - onboarding_checklist: res.data.onboarding_checklist, + onboarding_checklist: updatedChecklist, } }; }); } } catch (error) { - console.error("Failed to update onboarding checklist:", error); + logger.error("Failed to update onboarding checklist:", { error: getApiErrorMessage(error) }); } } } }, - [orgId, userScope, activeGroup?._id, dispatch] + [orgId, userScope, activeGroup?._id, dispatch, activeChecklistState.completedSteps, autoSteps, queryClient] ); const openAddDeviceChoice = React.useCallback(() => { @@ -316,73 +393,8 @@ const WelcomePage = () => { setNewlyClaimedDevice(undefined); }, [activeChecklistState.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" } - ); - 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]); // ── Early returns ────────────────────────────────────────────────────────── @@ -642,6 +654,9 @@ const WelcomePage = () => { message: "Workspace setup complete. You're ready to monitor and manage your devices.", type: "SUCCESS", }); + setTimeout(() => { + updateChecklist({ action: 'dismiss_checklist', dismissed: true }); + }, 2000); } }} /> diff --git a/src/vertex/core/apis/organizations.ts b/src/vertex/core/apis/organizations.ts index f3aaf15c3d..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,7 +12,7 @@ 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; @@ -20,7 +20,7 @@ export const groupsApi = { }, updateGroupOnboardingApi: async (groupId: string, payload: { action: 'mark_step_complete' | 'dismiss_checklist'; step_id?: string }) => { try { - const response = await createSecureApiClient().patch(`/users/groups/${groupId}/onboarding`, payload, { headers: { 'X-Auth-Type': 'JWT' } }); + const response = await secureApiProxy.patch(`/users/groups/${groupId}/onboarding`, payload, { headers: { 'X-Auth-Type': 'JWT' } }); return response.data; } catch (error) { throw error; @@ -28,7 +28,7 @@ export const groupsApi = { }, getGroupDetailsApi: async (groupId: string) => { try { - const response = await createSecureApiClient().get(`/users/groups/${groupId}`, { headers: { 'X-Auth-Type': 'JWT' } }); + const response = await secureApiProxy.get(`/users/groups/${groupId}`, { headers: { 'X-Auth-Type': 'JWT' } }); return response.data; } catch (error) { throw error; From c9e668046454de0ad6fb7e0d5e8baf3be1759814 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 12:30:26 +0300 Subject: [PATCH 21/23] Add group hooks and use in welcome page Introduce useGroupDetails and useUpdateGroupOnboarding hooks (useQuery/useMutation wrappers) in useGroups.ts and refactor the authenticated Home/Welcome page to use them. Replace direct groupsApi calls and a raw useQuery with the new hooks, import Group type, wire mutateAsync for onboarding updates, tighten setQueryData typing, and add the mutation to the effect deps. This centralizes group-related react-query logic and removes unused imports. --- src/vertex/app/(authenticated)/home/page.tsx | 22 +++++++++----------- src/vertex/core/hooks/useGroups.ts | 18 +++++++++++++++- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/vertex/app/(authenticated)/home/page.tsx b/src/vertex/app/(authenticated)/home/page.tsx index a51def8f22..d9b4282fbd 100644 --- a/src/vertex/app/(authenticated)/home/page.tsx +++ b/src/vertex/app/(authenticated)/home/page.tsx @@ -13,16 +13,15 @@ import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; import { Skeleton } from "@/components/ui/skeleton"; import { useDevices, useMyDevices } from "@/core/hooks/useDevices"; import { useGroupCohorts, usePersonalUserCohorts } from "@/core/hooks/useCohorts"; -import { useQueryClient, useQuery } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import ContextHeader from "@/components/features/home/context-header"; import NetworkVisibilityCard from "@/components/features/home/network-visibility-card"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import OnboardingChecklist from "@/components/features/home/onboarding-checklist"; import { cn } from "@/lib/utils"; import { Device } from "@/app/types/devices"; -import { groupsApi } from "@/core/apis/organizations"; - -const { updateGroupOnboardingApi, getGroupDetailsApi } = groupsApi; +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"; @@ -151,10 +150,7 @@ const WelcomePage = () => { ? `org_${activeGroup._id}` : null; - // Fetch up-to-date group details to ensure onboarding checklist is populated - const { data: groupDetailsData, isLoading: isLoadingGroupDetails } = useQuery({ - queryKey: ['groupDetails', activeGroup?._id], - queryFn: () => getGroupDetailsApi(activeGroup?._id as string), + const { data: groupDetailsData, isLoading: isLoadingGroupDetails } = useGroupDetails(activeGroup?._id as string, { enabled: userScope === "organisation" && !!activeGroup?._id, staleTime: 5 * 60 * 1000, }); @@ -251,6 +247,8 @@ const WelcomePage = () => { } }, [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") { @@ -287,19 +285,19 @@ const WelcomePage = () => { if (missingAutoSteps.length > 0) { for (const step of missingAutoSteps) { try { - await updateGroupOnboardingApi(activeGroup._id, { action: 'mark_step_complete', step_id: step }); + 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 updateGroupOnboardingApi(activeGroup._id, { action: patch.action, step_id: patch.step_id }); + 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: any) => { + queryClient.setQueryData(['groupDetails', activeGroup._id], (old: { group?: Group } | undefined) => { if (!old || !old.group) return old; return { ...old, @@ -316,7 +314,7 @@ const WelcomePage = () => { } } }, - [orgId, userScope, activeGroup?._id, dispatch, activeChecklistState.completedSteps, autoSteps, queryClient] + [orgId, userScope, activeGroup?._id, dispatch, activeChecklistState.completedSteps, autoSteps, queryClient, updateGroupOnboarding] ); const openAddDeviceChoice = React.useCallback(() => { 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), + }); +}; + From 4574438b91898aaa1fdcaf3e2eb79468cbc07406 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 12:39:05 +0300 Subject: [PATCH 22/23] Update changelog.md --- src/vertex/app/changelog.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 35c33b2968..131d459452 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -4,6 +4,34 @@ --- +## 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 From 44c2f623aea04880c7c5d2283df5222cbbe7f318 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 12:53:11 +0300 Subject: [PATCH 23/23] Add cleanup for setTimeout to prevent post-unmount execution. --- src/vertex/app/(authenticated)/home/page.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/vertex/app/(authenticated)/home/page.tsx b/src/vertex/app/(authenticated)/home/page.tsx index d9b4282fbd..fa0bf88607 100644 --- a/src/vertex/app/(authenticated)/home/page.tsx +++ b/src/vertex/app/(authenticated)/home/page.tsx @@ -137,6 +137,14 @@ const WelcomePage = () => { 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; @@ -653,7 +661,9 @@ const WelcomePage = () => { type: "SUCCESS", }); setTimeout(() => { - updateChecklist({ action: 'dismiss_checklist', dismissed: true }); + if (isMounted.current) { + updateChecklist({ action: 'dismiss_checklist', dismissed: true }); + } }, 2000); } }}