Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/vertex/app/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,45 @@

---

## Version 2.0.2
**Released:** June 10, 2026
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### Resilient API Routing & Social Auth Safety Fixes

This release aligns the Vertex application's social login and API routing behavior with the more robust patterns used in the platform project, creating a significant safety net around our authentication flow and API communication.

<details>
<summary><strong>Resilient API Routing (2)</strong></summary>

- **Multi-Fallback Routing**: Replaced single-variable URL concatenation (`getApiBaseUrl()`) with `resolveApiOrigin()` and `resolveVersionedApiPath()`. The app now automatically scans multiple environment variables (`API_BASE_URL`, `NEXT_PUBLIC_API_BASE_URL`, `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_BASE_URL`) before failing.
- **Suffix Stripping**: Implemented automatic `/api/v2` suffix stripping to prevent duplicate path segments caused by slight CI/CD misconfigurations.

</details>

<details>
<summary><strong>Social Authentication Fixes (3)</strong></summary>

- **Sign-Out Safety Flag**: Added the `OAUTH_SIGNED_OUT_FLAG` pattern to prevent "phantom token" bootstrapping if a user signs out and uses the browser back button to navigate to an old OAuth callback URL. The flag is explicitly cleared on the next intentional login to prevent redirect loops.
- **Google Account Picker**: Explicitly injected `prompt=select_account` into the Google OAuth initialization query parameters to prevent Google from silently logging users back into their active browser session.
- **Fail-Open Render Guard**: Removed the silent render guard from the Social Auth component. The buttons will now always render and gracefully handle URL resolution errors by displaying a UI banner rather than mysteriously vanishing.

</details>

<details>
<summary><strong>Files Modified (7)</strong></summary>

- `lib/envConstants.ts` [MODIFIED]
- `lib/api-routing.ts` [MODIFIED]
- `vertex.config.ts` [MODIFIED]
- `components/features/auth/social-auth-section.tsx` [MODIFIED]
- `core/auth/oauth-session.ts` [MODIFIED]
- `core/auth/authProvider.tsx` [MODIFIED]
- `core/hooks/useLogout.ts` [MODIFIED]

</details>

---

## Version 2.0.1
**Released:** June 09, 2026

Expand Down
11 changes: 6 additions & 5 deletions src/vertex/components/features/auth/social-auth-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getLastUsedOAuthProvider,
resolveOAuthRedirectAfterUrl,
setLastUsedOAuthProvider,
clearBackendOAuthSignedOutFlag,
type SupportedSocialAuthProvider,
} from '@/core/auth/oauth-session';
import { cn } from '@/lib/utils';
Expand Down Expand Up @@ -93,9 +94,14 @@ export default function SocialAuthSection({
queryParams.redirect_after = redirectAfter;
}

if (provider === 'google') {
queryParams.prompt = 'select_account';
}


try {
setLastUsedOAuthProvider(provider);
clearBackendOAuthSignedOutFlag();
window.location.replace(buildOAuthInitiationUrl(provider, queryParams));
} catch (error) {
showBanner({
Expand All @@ -109,11 +115,6 @@ export default function SocialAuthSection({
[disabled, redirectPath, showBanner]
);

// Hide social auth completely if the required API URL environment variable is missing
// to prevent runtime crashes when getApiBaseUrl() throws an error during OAuth initiation.
if (!process.env.NEXT_PUBLIC_API_URL) {
return null;
}

return (
<div className={cn('w-full space-y-4', className)}>
Expand Down
10 changes: 9 additions & 1 deletion src/vertex/core/auth/authProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import type {
import { ExtendedSession } from '../utils/secureApiProxyClient';
import { useLogout } from '@/core/hooks/useLogout';
import logger from '@/lib/logger';
import { consumeOAuthTokenHandoffFromUrl } from './oauth-session';
import { consumeOAuthTokenHandoffFromUrl, shouldSkipBackendOAuthBootstrap, clearBackendOAuthSignedOutFlag } from './oauth-session';

// --- Helper Functions ---

Expand Down Expand Up @@ -623,6 +623,13 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) {
let shouldUnblock = true;
const bootstrap = async () => {
try {
if (shouldSkipBackendOAuthBootstrap()) {
logger.debug('[TokenHandoffHandler] Skipping OAuth bootstrap due to signed-out flag');
isHandlingOAuthRef.current = false;
shouldUnblock = true;
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const handoff = consumeOAuthTokenHandoffFromUrl();
if (handoff?.token) {
logger.info('[TokenHandoffHandler] OAuth token detected, signing in...');
Comment thread
Copilot marked this conversation as resolved.
Expand All @@ -634,6 +641,7 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) {
});

if (result?.ok) {
clearBackendOAuthSignedOutFlag();
// Force NextAuth SessionProvider to immediately sync its React context
await update();

Expand Down
25 changes: 25 additions & 0 deletions src/vertex/core/auth/oauth-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { buildServerApiUrl } from "@/lib/api-routing";
const OAUTH_FRAGMENT_TOKEN_KEY = 'token';
const OAUTH_SUCCESS_PROVIDER_KEY = 'success';
const LAST_USED_OAUTH_PROVIDER_KEY = 'vertex:last-oauth-provider';
const OAUTH_SIGNED_OUT_FLAG = 'vertex:oauth-signed-out';

export const SUPPORTED_SOCIAL_AUTH_PROVIDERS = [
'google',
Expand Down Expand Up @@ -68,6 +69,30 @@ export const setLastUsedOAuthProvider = (
localStorage.setItem(LAST_USED_OAUTH_PROVIDER_KEY, provider);
};

export const shouldSkipBackendOAuthBootstrap = (): boolean => {
if (typeof window === 'undefined') {
return false;
}

return localStorage.getItem(OAUTH_SIGNED_OUT_FLAG) === 'true';
};

export const clearBackendOAuthSignedOutFlag = (): void => {
if (typeof window === 'undefined') {
return;
}

localStorage.removeItem(OAUTH_SIGNED_OUT_FLAG);
};

export const setBackendOAuthSignedOutFlag = (): void => {
if (typeof window === 'undefined') {
return;
}

localStorage.setItem(OAUTH_SIGNED_OUT_FLAG, 'true');
};

export interface OAuthTokenHandoff {
token: string;
provider: string | null;
Expand Down
2 changes: 2 additions & 0 deletions src/vertex/core/hooks/useLogout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { rememberAccount } from '../utils/rememberedAccounts';
import logger from '@/lib/logger';
import { useAppSelector, useAppDispatch } from '../redux/hooks';
import { persistor } from '../redux/store';
import { setBackendOAuthSignedOutFlag } from '../auth/oauth-session';

let sharedLogoutPromise: Promise<void> | null = null;
let sharedIsLoggingOut = false;
Expand Down Expand Up @@ -65,6 +66,7 @@ export const useLogout = (callbackUrl?: string) => {
queryClient.clear();
await persistor.purge();

setBackendOAuthSignedOutFlag();
await signOut({ redirect: false });
router.push(callbackUrl || '/login');
} catch (error) {
Expand Down
139 changes: 108 additions & 31 deletions src/vertex/lib/api-routing.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,124 @@
import { getApiBaseUrl } from './envConstants';
const DEFAULT_API_VERSION_FALLBACK = 'v2';

const ensureLeadingSlash = (value: string): string => {
if (!value) return '';
return value.startsWith('/') ? value : `/${value}`;
const resolveDefaultApiVersion = (): string => {
return DEFAULT_API_VERSION_FALLBACK;
};

const resolveServiceVersionMap = (): Record<string, string> => {
return {};
};

const stripApiSuffix = (baseUrl: string): string => {
const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, '');

if (/\/api\/v\d+\/[^/]+$/i.test(trimmedBaseUrl)) {
return trimmedBaseUrl.replace(/\/api\/v\d+\/[^/]+$/i, '');
}

if (/\/api\/v\d+$/i.test(trimmedBaseUrl)) {
return trimmedBaseUrl.replace(/\/api\/v\d+$/i, '');
}

if (/\/api$/i.test(trimmedBaseUrl)) {
return trimmedBaseUrl.replace(/\/api$/i, '');
}

return trimmedBaseUrl;
};

const splitPathAndQuery = (value: string): { path: string; query: string } => {
const queryStartIndex = value.indexOf('?');
if (queryStartIndex === -1) {
return { path: value, query: '' };
}

return {
path: value.slice(0, queryStartIndex),
query: value.slice(queryStartIndex),
};
};

const isAbsoluteUrl = (value: string): boolean => {
return /^https?:\/\//i.test(value);
};

/**
* Builds an absolute API URL for server-side requests.
* Uses the environment-configured API base URL.
*
* @param inputPath - The path to append to the base URL (e.g. '/users/profile')
* @returns The absolute URL string
*/
export const buildServerApiUrl = (inputPath: string): string => {
const ensureLeadingSlash = (value: string): string => {
return value.startsWith('/') ? value : `/${value}`;
};

const isAlreadyVersionedPath = (value: string): boolean => {
return /^\/?api\/v\d+\//i.test(value) || /^\/?api\/v\d+$/i.test(value);
};

export const resolveApiOrigin = (): string => {
const configuredBaseUrl =
process.env.API_BASE_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.NEXT_PUBLIC_API_URL ||
process.env.NEXT_PUBLIC_BASE_URL ||
'';

const normalizedOrigin = stripApiSuffix(configuredBaseUrl);
if (!normalizedOrigin) {
throw new Error(
'API base URL is not defined. Set NEXT_PUBLIC_API_URL or API_BASE_URL in environment variables.'
);
Comment on lines +61 to +65
}

return normalizedOrigin;
};

export const resolveVersionedApiPath = (inputPath: string): string => {
const trimmedInput = (inputPath || '').trim();

if (!trimmedInput) {
return `/api/${resolveDefaultApiVersion()}`;
}

if (isAbsoluteUrl(trimmedInput)) {
return trimmedInput;
}

const baseUrl = getApiBaseUrl(); // E.g., https://staging-vertex.airqo.net/api/v2
return `${baseUrl}${ensureLeadingSlash(trimmedInput)}`;

const { path, query } = splitPathAndQuery(trimmedInput);
const normalizedPath = ensureLeadingSlash(path.trim());

if (isAlreadyVersionedPath(normalizedPath)) {
return `${normalizedPath}${query}`;
}

const noLeadingSlashPath = normalizedPath.replace(/^\/+/, '');
if (
/^v\d+\//i.test(noLeadingSlashPath) ||
/^v\d+$/i.test(noLeadingSlashPath)
) {
return `/api/${noLeadingSlashPath}${query}`;
}

const segments = noLeadingSlashPath.split('/').filter(Boolean);
if (segments.length === 0) {
return `/api/${resolveDefaultApiVersion()}${query}`;
}

const service = segments[0].toLowerCase();
const serviceVersionMap = resolveServiceVersionMap();
const apiVersion = serviceVersionMap[service] || resolveDefaultApiVersion();

return `/api/${apiVersion}/${segments.join('/')}${query}`;
};

export const buildServerApiUrl = (inputPath: string): string => {
const versionedPath = resolveVersionedApiPath(inputPath);
if (isAbsoluteUrl(versionedPath)) {
return versionedPath;
}

return `${resolveApiOrigin()}${versionedPath}`;
};

/**
* Builds a relative API URL for browser-side requests.
* By returning a relative path, the browser natively routes to the current domain,
* completely eliminating "split-brain" environment variable issues on the client.
*
* @param inputPath - The path to format (e.g. '/users/profile')
* @returns The relative URL string (e.g. '/api/v2/users/profile')
*/
export const buildBrowserApiUrl = (inputPath: string): string => {
const trimmedInput = (inputPath || '').trim();

if (isAbsoluteUrl(trimmedInput)) {
return trimmedInput;
const versionedPath = resolveVersionedApiPath(inputPath);
if (isAbsoluteUrl(versionedPath)) {
return versionedPath;
}

// The vertex backend sits under /api/v2 relative to the frontend domain
return `/api/v2${ensureLeadingSlash(trimmedInput)}`;

return versionedPath;
};
13 changes: 1 addition & 12 deletions src/vertex/lib/envConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Environment constants and utilities for API configuration
* Centralizes environment variable access with proper validation
*/
import { stripTrailingSlash } from './utils';


/**
* Gets the current environment from environment variables
Expand All @@ -12,17 +12,6 @@ export const getEnvironment = (): string => {
return process.env.NEXT_PUBLIC_ENV || 'development';
};

/**
* Gets the API base URL from environment variables
* @returns {string} The API base URL
*/
export const getApiBaseUrl = (): string => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiUrl) {
throw new Error('API base URL is not defined. Set NEXT_PUBLIC_API_URL in environment variables.');
}
return stripTrailingSlash(apiUrl);
};

/**
* Gets the API token from environment variables (server-side only)
Expand Down
7 changes: 3 additions & 4 deletions src/vertex/vertex.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
validateVertexConfig,
type VertexConfigInput,
} from "./core/config/vertex-config";
import { getApiBaseUrl } from "./lib/envConstants";
import { resolveApiOrigin } from "./lib/api-routing";

const config: VertexConfigInput = {
...defaultVertexConfig,
Expand All @@ -18,9 +18,8 @@ const config: VertexConfigInput = {
},
api: {
adapter: "airqo",
baseUrl: getApiBaseUrl(),
publicMeasurementsBaseUrl:
process.env.NEXT_PUBLIC_API_BASE_URL || getApiBaseUrl(),
baseUrl: resolveApiOrigin(),
publicMeasurementsBaseUrl: resolveApiOrigin(),
},
auth: {
provider: "airqo",
Expand Down
Loading