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; - }; - } -}