diff --git a/src/platform/src/modules/billing/components/SubscriptionSection.tsx b/src/platform/src/modules/billing/components/SubscriptionSection.tsx
index b1bcc4e9b9..cc6d83686d 100644
--- a/src/platform/src/modules/billing/components/SubscriptionSection.tsx
+++ b/src/platform/src/modules/billing/components/SubscriptionSection.tsx
@@ -9,7 +9,13 @@ import React, {
} from 'react';
import { useSession } from 'next-auth/react';
import { AqCheck } from '@airqo/icons-react';
-import { Button, Card, LoadingSpinner, toast } from '@/shared/components/ui';
+import {
+ Banner,
+ Button,
+ Card,
+ LoadingSpinner,
+ toast,
+} from '@/shared/components/ui';
import ReusableDialog from '@/shared/components/ui/dialog';
import { PADDLE_CHECKOUT_COMPLETED_EVENT } from '@/shared/lib/paddle';
import { formatDate } from '@/shared/utils';
@@ -196,7 +202,7 @@ const SubscriptionSection: React.FC = () => {
const statusNotice = useMemo(() => {
if (currentStatus === 'past_due') {
return {
- tone: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200',
+ severity: 'warning' as const,
eyebrow: 'Action required',
title: 'We could not complete your latest renewal',
message:
@@ -207,7 +213,7 @@ const SubscriptionSection: React.FC = () => {
if (currentStatus === 'cancelled') {
if (accessDateText) {
return {
- tone: 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900/40 dark:bg-sky-950/20 dark:text-sky-200',
+ severity: 'info' as const,
eyebrow: 'Renewal off',
title: `Paid access remains available through ${accessDateText}`,
message:
@@ -216,7 +222,7 @@ const SubscriptionSection: React.FC = () => {
}
return {
- tone: 'border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-800 dark:bg-slate-900/40 dark:text-slate-200',
+ severity: 'info' as const,
eyebrow: 'Subscription ended',
title: 'This paid plan is no longer active',
message:
@@ -226,7 +232,7 @@ const SubscriptionSection: React.FC = () => {
if (currentStatus === 'paused') {
return {
- tone: 'border-violet-200 bg-violet-50 text-violet-900 dark:border-violet-900/40 dark:bg-violet-950/20 dark:text-violet-200',
+ severity: 'info' as const,
eyebrow: 'Subscription paused',
title: 'Billing is paused for this plan',
message:
@@ -236,7 +242,7 @@ const SubscriptionSection: React.FC = () => {
if (currentTier !== 'Free' && !subscription?.automaticRenewal) {
return {
- tone: 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900/40 dark:bg-sky-950/20 dark:text-sky-200',
+ severity: 'info' as const,
eyebrow: 'Renewal off',
title: accessDateText
? `Your plan stays active through ${accessDateText}`
@@ -585,17 +591,21 @@ const SubscriptionSection: React.FC = () => {
{statusNotice && (
-
-
- {statusNotice.eyebrow}
-
-
- {statusNotice.title}
-
-
{statusNotice.message}
-
+
+
+ {statusNotice.eyebrow}
+
+
+ {statusNotice.title}
+
+ {statusNotice.message}
+
+ }
+ />
)}
@@ -653,7 +663,7 @@ const SubscriptionSection: React.FC = () => {
onClick={() => setPendingConfirmation('cancel')}
disabled={runningAction === 'cancel'}
loading={runningAction === 'cancel'}
- className="border-rose-600 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-900/20"
+ className="border-rose-600 text-rose-600 hover:!border-rose-600 hover:!bg-rose-600 hover:!text-white dark:hover:!border-rose-500 dark:hover:!bg-rose-500 dark:hover:!text-white"
>
Cancel Plan
diff --git a/src/platform/src/next-auth.d.ts b/src/platform/src/next-auth.d.ts
index 6f5438e3be..ef7973cf41 100644
--- a/src/platform/src/next-auth.d.ts
+++ b/src/platform/src/next-auth.d.ts
@@ -1,6 +1,12 @@
import type { DefaultSession, DefaultUser } from 'next-auth';
import type { AuthMethods } from '@/shared/types/api';
+type SessionUser = NonNullable
& {
+ _id?: string;
+ firstName?: string;
+ lastName?: string;
+};
+
declare module 'next-auth' {
interface User extends DefaultUser {
_id?: string;
@@ -18,7 +24,7 @@ declare module 'next-auth' {
accessToken?: string;
expiresAt?: string;
authMethods?: AuthMethods;
- user: (DefaultSession['user'] & User) | null;
+ user: SessionUser | null;
}
}
diff --git a/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx b/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx
index 0de07eaeed..0f3a2d6465 100644
--- a/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx
+++ b/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx
@@ -1,12 +1,13 @@
'use client';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from 'next-auth/react';
import ReusableDialog from '@/shared/components/ui/dialog';
import { Banner, Input, toast } from '@/shared/components/ui';
import { useSetPassword } from '@/shared/hooks';
+import { verifyBackendOAuthSession } from '@/shared/lib/oauth-session';
import {
setPasswordSchema,
type SetPasswordFormData,
@@ -16,19 +17,6 @@ import type { AuthMethods } from '@/shared/types/api';
const DISMISS_STORAGE_KEY_PREFIX = 'airqo:set-password-dismissed:';
const SET_PASSWORD_PROMPT_DELAY_MS = 5000;
-const CONNECTED_PROVIDER_LABELS: Record<
- Exclude,
- string
-> = {
- google: 'Google',
- github: 'GitHub',
- linkedin: 'LinkedIn',
- microsoft: 'Microsoft',
- twitter: 'X',
- facebook: 'Facebook',
- apple: 'Apple',
-};
-
const DEFAULT_AUTH_METHODS: AuthMethods = {
password: false,
google: false,
@@ -40,38 +28,24 @@ const DEFAULT_AUTH_METHODS: AuthMethods = {
apple: false,
};
-const buildProviderSummary = (providers: string[]) => {
- if (!providers.length) {
- return 'a connected account';
- }
-
- if (providers.length === 1) {
- return providers[0];
- }
-
- if (providers.length === 2) {
- return `${providers[0]} and ${providers[1]}`;
- }
-
- return `${providers.slice(0, -1).join(', ')}, and ${providers.at(-1)}`;
-};
+type SessionRefreshOutcome = 'fresh' | 'fallback' | 'failed';
const SetPasswordPromptDialog = () => {
const { data: session, status, update } = useSession();
const { trigger: setPassword, isMutating } = useSetPassword();
const [isOpen, setIsOpen] = useState(false);
const openTimerRef = useRef(null);
+ const isMountedRef = useRef(true);
+ const refreshControllerRef = useRef(null);
const sessionData = session as {
authMethods?: AuthMethods;
user?: {
_id?: string | null;
email?: string | null;
- authMethods?: AuthMethods;
} | null;
} | null;
- const authMethods =
- sessionData?.authMethods || sessionData?.user?.authMethods || undefined;
+ const authMethods = sessionData?.authMethods;
const hasAuthMethods = !!authMethods;
const hasPassword = authMethods?.password === true;
const userId =
@@ -79,24 +53,6 @@ const SetPasswordPromptDialog = () => {
const dismissStorageKey = userId
? `${DISMISS_STORAGE_KEY_PREFIX}${userId}`
: null;
- const linkedProviders = useMemo(
- () =>
- authMethods
- ? (
- Object.entries(CONNECTED_PROVIDER_LABELS) as Array<
- [Exclude, string]
- >
- )
- .filter(([provider]) => authMethods[provider])
- .map(([, label]) => label)
- : [],
- [authMethods]
- );
- const providerSummary = useMemo(
- () => buildProviderSummary(linkedProviders),
- [linkedProviders]
- );
-
const {
register,
handleSubmit,
@@ -111,6 +67,15 @@ const SetPasswordPromptDialog = () => {
mode: 'onChange',
});
+ useEffect(() => {
+ isMountedRef.current = true;
+
+ return () => {
+ isMountedRef.current = false;
+ refreshControllerRef.current?.abort();
+ };
+ }, []);
+
useEffect(() => {
if (openTimerRef.current) {
window.clearTimeout(openTimerRef.current);
@@ -155,43 +120,96 @@ const SetPasswordPromptDialog = () => {
setIsOpen(false);
};
- const markPasswordAsConfigured = async (): Promise => {
- const nextAuthMethods: AuthMethods = {
- ...DEFAULT_AUTH_METHODS,
- ...authMethods,
- password: true,
- };
+ const refreshSessionState = async (
+ fallbackAuthMethods: AuthMethods
+ ): Promise => {
+ refreshControllerRef.current?.abort();
+
+ const controller = new AbortController();
+ refreshControllerRef.current = controller;
try {
- await update({ authMethods: nextAuthMethods });
- return true;
+ const refreshedProfile = await verifyBackendOAuthSession({
+ signal: controller.signal,
+ });
+
+ if (!isMountedRef.current) {
+ return 'failed';
+ }
+
+ await update({
+ ...(refreshedProfile?.accessToken
+ ? { accessToken: refreshedProfile.accessToken }
+ : {}),
+ authMethods: refreshedProfile?.authMethods ?? fallbackAuthMethods,
+ });
+
+ return refreshedProfile?.authMethods ? 'fresh' : 'fallback';
} catch (error) {
- console.warn('Failed to refresh session after setting password', error);
- return false;
+ const errorName = (error as { name?: string })?.name;
+ if (errorName !== 'AbortError') {
+ console.warn('Failed to refresh session after setting password', error);
+ }
+
+ if (!isMountedRef.current || errorName === 'AbortError') {
+ return 'failed';
+ }
+
+ try {
+ await update({ authMethods: fallbackAuthMethods });
+ return 'fallback';
+ } catch (updateError) {
+ console.warn(
+ 'Failed to apply fallback auth methods after setting password',
+ updateError
+ );
+ return 'failed';
+ }
} finally {
+ if (refreshControllerRef.current === controller) {
+ refreshControllerRef.current = null;
+ }
+
if (dismissStorageKey) {
sessionStorage.removeItem(dismissStorageKey);
}
- setIsOpen(false);
+ if (isMountedRef.current) {
+ setIsOpen(false);
+ }
}
};
const onSubmit = async (data: SetPasswordFormData) => {
+ const nextAuthMethods: AuthMethods = {
+ ...DEFAULT_AUTH_METHODS,
+ ...authMethods,
+ password: true,
+ };
+
try {
await setPassword({
password: data.password,
confirmPassword: data.confirmPassword,
});
- const sessionUpdated = await markPasswordAsConfigured();
+ const refreshOutcome = await refreshSessionState(nextAuthMethods);
+ if (!isMountedRef.current) {
+ return;
+ }
+
reset();
- if (sessionUpdated) {
+ if (refreshOutcome === 'fresh') {
toast.success(
'Password added',
'You can now sign in with your email and password whenever you need to.'
);
+ } else if (refreshOutcome === 'fallback') {
+ toast.warning(
+ 'Password saved',
+ 'Your password was saved, but the latest sign-in methods could not be confirmed from the server yet. This session was updated locally to avoid stale prompts.'
+ );
} else {
toast.warning(
'Password saved',
@@ -205,13 +223,27 @@ const SetPasswordPromptDialog = () => {
: 'Unable to set your password right now.';
if (message.toLowerCase().includes('already set')) {
- const sessionUpdated = await markPasswordAsConfigured();
+ const nextAuthMethods: AuthMethods = {
+ ...DEFAULT_AUTH_METHODS,
+ ...authMethods,
+ password: true,
+ };
+ const refreshOutcome = await refreshSessionState(nextAuthMethods);
+
+ if (!isMountedRef.current) {
+ return;
+ }
- if (sessionUpdated) {
+ if (refreshOutcome === 'fresh') {
toast.success(
'Password already available',
'Your account already has a password. You can manage it from Security settings.'
);
+ } else if (refreshOutcome === 'fallback') {
+ toast.warning(
+ 'Password already available',
+ 'Your account already has a password. This session was updated locally while the latest auth settings sync catches up.'
+ );
} else {
toast.warning(
'Password already available',
@@ -234,8 +266,9 @@ const SetPasswordPromptDialog = () => {
isOpen={isOpen}
onClose={closeForNow}
title="Add a password for direct sign-in"
- subtitle={`You're signed in with ${providerSummary}. Add a password to enable direct email sign-in for this account.`}
+ subtitle="You're signed in with a third-party provider. Add a password to enable direct email sign-in for this account."
size="lg"
+ className="outline-none focus:outline-none focus-visible:outline-none ring-0"
preventBackdropClose={isMutating}
primaryAction={{
label: isMutating ? 'Saving...' : 'Save password',
@@ -263,7 +296,7 @@ const SetPasswordPromptDialog = () => {
diff --git a/src/platform/src/shared/lib/auth.ts b/src/platform/src/shared/lib/auth.ts
index 8bc66e3a4e..36abe275db 100644
--- a/src/platform/src/shared/lib/auth.ts
+++ b/src/platform/src/shared/lib/auth.ts
@@ -1,8 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import CredentialsProvider from 'next-auth/providers/credentials';
import { authService } from '../services/authService';
-import { buildServerApiUrl } from '@/shared/lib/api-routing';
-import { normalizeOAuthAccessToken } from './oauth-session';
+import {
+ fetchEnhancedUserProfile,
+ normalizeOAuthAccessToken,
+ type BackendOAuthProfile,
+} from './oauth-session';
import type { AuthMethods } from '@/shared/types/api';
const isProduction = process.env.NODE_ENV === 'production';
@@ -26,59 +29,10 @@ const isExpiredAt = (value: unknown): boolean => {
return expiresAt <= Date.now();
};
-interface OAuthProfilePayload {
- _id: string;
- email: string;
- firstName: string;
- lastName: string;
- profilePicture?: string;
- authMethods?: AuthMethods;
-}
-
-interface OAuthProfileResponse {
- success: boolean;
- message?: string;
- data?: OAuthProfilePayload;
-}
-
const fetchOAuthProfile = async (
accessToken: string
-): Promise => {
- let profileUrl = '';
-
- try {
- profileUrl = buildServerApiUrl('/users/profile/enhanced');
- } catch {
- profileUrl = '';
- }
-
- if (!profileUrl) {
- return null;
- }
-
- try {
- const response = await fetch(profileUrl, {
- method: 'GET',
- cache: 'no-store',
- headers: {
- Accept: 'application/json',
- Authorization: `JWT ${accessToken}`,
- },
- });
-
- if (!response.ok) {
- return null;
- }
-
- const payload = (await response.json()) as OAuthProfileResponse;
- if (!payload?.success || !payload.data?._id) {
- return null;
- }
-
- return payload.data;
- } catch {
- return null;
- }
+): Promise => {
+ return fetchEnhancedUserProfile({ accessToken });
};
// Helper function to check token expiration and log
@@ -301,9 +255,6 @@ export const authOptions: any = {
(session.user as any)._id = (token as any)._id;
(session.user as any).firstName = (token as any).firstName;
(session.user as any).lastName = (token as any).lastName;
- (session.user as any).accessToken = accessToken;
- (session.user as any).expiresAt = expiresAt;
- (session.user as any).authMethods = authMethods;
}
return session;
},
diff --git a/src/platform/src/shared/lib/oauth-session.ts b/src/platform/src/shared/lib/oauth-session.ts
index b23c944e36..e734af4947 100644
--- a/src/platform/src/shared/lib/oauth-session.ts
+++ b/src/platform/src/shared/lib/oauth-session.ts
@@ -50,10 +50,14 @@ export interface BackendOAuthSession {
lastName: string;
name: string;
image: string;
- authMethods?: AuthMethods;
};
}
+export interface FetchEnhancedUserProfileOptions {
+ accessToken?: string;
+ signal?: AbortSignal;
+}
+
export interface OAuthTokenHandoff {
token: string;
provider: string | null;
@@ -295,60 +299,91 @@ export const buildSessionFromProfile = (
lastName: profile.lastName,
name: fullName || profile.email,
image: profile.profilePicture ?? '',
- authMethods,
},
};
};
-export const verifyBackendOAuthSession =
- async (): Promise => {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => {
- controller.abort();
- }, OAUTH_PROFILE_FETCH_TIMEOUT_MS);
-
- try {
- const profileUrl =
- typeof window === 'undefined'
- ? buildServerApiUrl('/users/profile/enhanced')
- : buildBrowserApiUrl('/users/profile/enhanced');
-
- const response = await fetch(profileUrl, {
- method: 'GET',
- signal: controller.signal,
- credentials: 'include',
- cache: 'no-store',
- headers: {
- Accept: 'application/json',
- },
- });
-
- if (!response.ok) {
- return null;
- }
+const resolveEnhancedUserProfileUrl = (): string => {
+ return typeof window === 'undefined'
+ ? buildServerApiUrl('/users/profile/enhanced')
+ : buildBrowserApiUrl('/users/profile/enhanced');
+};
- const payload = (await response.json()) as BackendOAuthProfileResponse;
- if (!payload?.success || !payload.data?._id) {
- return null;
- }
+const normalizeBackendOAuthProfile = (
+ payload: BackendOAuthProfileResponse
+): BackendOAuthProfile | null => {
+ if (!payload?.success || !payload.data?._id) {
+ return null;
+ }
- return {
- ...payload.data,
- accessToken: payload.data.accessToken
- ? normalizeOAuthAccessToken(payload.data.accessToken) || undefined
- : payload.accessToken
- ? normalizeOAuthAccessToken(payload.accessToken) || undefined
- : undefined,
- authMethods: normalizeAuthMethods(payload.data.authMethods),
- };
- } catch (error) {
- const errorName = (error as { name?: string })?.name;
- if (errorName === 'AbortError') {
- return null;
- }
+ return {
+ ...payload.data,
+ accessToken: payload.data.accessToken
+ ? normalizeOAuthAccessToken(payload.data.accessToken) || undefined
+ : payload.accessToken
+ ? normalizeOAuthAccessToken(payload.accessToken) || undefined
+ : undefined,
+ authMethods: normalizeAuthMethods(payload.data.authMethods),
+ };
+};
+export const fetchEnhancedUserProfile = async (
+ options: FetchEnhancedUserProfileOptions = {}
+): Promise => {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => {
+ controller.abort();
+ }, OAUTH_PROFILE_FETCH_TIMEOUT_MS);
+ const normalizedAccessToken = normalizeOAuthAccessToken(
+ typeof options.accessToken === 'string' ? options.accessToken : ''
+ );
+ const handleExternalAbort = () => {
+ controller.abort();
+ };
+
+ if (options.signal?.aborted) {
+ controller.abort();
+ } else if (options.signal) {
+ options.signal.addEventListener('abort', handleExternalAbort, {
+ once: true,
+ });
+ }
+
+ try {
+ const response = await fetch(resolveEnhancedUserProfileUrl(), {
+ method: 'GET',
+ signal: controller.signal,
+ credentials: 'include',
+ cache: 'no-store',
+ headers: {
+ Accept: 'application/json',
+ ...(normalizedAccessToken
+ ? { Authorization: `JWT ${normalizedAccessToken}` }
+ : {}),
+ },
+ });
+
+ if (!response.ok) {
return null;
- } finally {
- clearTimeout(timeoutId);
}
- };
+
+ const payload = (await response.json()) as BackendOAuthProfileResponse;
+ return normalizeBackendOAuthProfile(payload);
+ } catch (error) {
+ const errorName = (error as { name?: string })?.name;
+ if (errorName === 'AbortError') {
+ return null;
+ }
+
+ return null;
+ } finally {
+ clearTimeout(timeoutId);
+ options.signal?.removeEventListener('abort', handleExternalAbort);
+ }
+};
+
+export const verifyBackendOAuthSession = async (
+ options: Omit = {}
+): Promise => {
+ return fetchEnhancedUserProfile(options);
+};
diff --git a/src/platform/src/shared/services/apiClient.ts b/src/platform/src/shared/services/apiClient.ts
index 7290bad1b8..c18a5d6f87 100644
--- a/src/platform/src/shared/services/apiClient.ts
+++ b/src/platform/src/shared/services/apiClient.ts
@@ -37,10 +37,7 @@ const _refreshAuthToken = async (): Promise<{
}> => {
const session = await getSession();
const currentToken = normalizeOAuthAccessToken(
- (session as { accessToken?: string; user?: { accessToken?: string } })
- ?.accessToken ||
- (session as { user?: { accessToken?: string } })?.user?.accessToken ||
- ''
+ (session as { accessToken?: string })?.accessToken || ''
);
if (!currentToken) {
diff --git a/src/platform/src/shared/services/sessionAuthToken.ts b/src/platform/src/shared/services/sessionAuthToken.ts
index bb90ccbc2d..696f066c56 100644
--- a/src/platform/src/shared/services/sessionAuthToken.ts
+++ b/src/platform/src/shared/services/sessionAuthToken.ts
@@ -16,11 +16,10 @@ let cacheVersion = 0;
const extractSessionAccessToken = (session: unknown): string | null => {
const candidate = session as {
- accessToken?: string;
- user?: { accessToken?: string | null } | null;
+ accessToken?: string | null;
} | null;
- const accessToken = candidate?.accessToken ?? candidate?.user?.accessToken;
+ const accessToken = candidate?.accessToken;
if (typeof accessToken !== 'string') {
return null;
}
diff --git a/src/platform/src/shared/types/global.d.ts b/src/platform/src/shared/types/global.d.ts
index d16209e066..35306c6fc9 100644
--- a/src/platform/src/shared/types/global.d.ts
+++ b/src/platform/src/shared/types/global.d.ts
@@ -1,50 +1 @@
declare module '*.css';
-
-declare module 'next-auth' {
- interface Session {
- accessToken?: string;
- expiresAt?: string;
- authMethods?: {
- password: boolean;
- google: boolean;
- github: boolean;
- linkedin: boolean;
- microsoft: boolean;
- twitter: boolean;
- facebook: boolean;
- apple: boolean;
- };
- }
-
- interface User {
- accessToken?: string;
- expiresAt?: string;
- authMethods?: {
- password: boolean;
- google: boolean;
- github: boolean;
- linkedin: boolean;
- microsoft: boolean;
- twitter: boolean;
- facebook: boolean;
- apple: boolean;
- };
- }
-}
-
-declare module 'next-auth/jwt' {
- interface JWT {
- accessToken?: string;
- expiresAt?: string;
- authMethods?: {
- password: boolean;
- google: boolean;
- github: boolean;
- linkedin: boolean;
- microsoft: boolean;
- twitter: boolean;
- facebook: boolean;
- apple: boolean;
- };
- }
-}