diff --git a/src/platform/middleware.ts b/src/platform/middleware.ts index 1e1cfd2123..3f284632e3 100644 --- a/src/platform/middleware.ts +++ b/src/platform/middleware.ts @@ -26,5 +26,5 @@ export default withAuth( ); export const config = { - matcher: ['/user/:path*', '/org/:path*', '/org-invite'], + matcher: ['/user/:path*', '/org/:path*'], }; diff --git a/src/platform/src/app/(dashboard)/request-organization/layout.tsx b/src/platform/src/app/(dashboard)/request-organization/layout.tsx index b2785dbaac..edae59f955 100644 --- a/src/platform/src/app/(dashboard)/request-organization/layout.tsx +++ b/src/platform/src/app/(dashboard)/request-organization/layout.tsx @@ -14,7 +14,14 @@ export const metadata: Metadata = { const RequestOrganizationLayout = ({ children, }: RequestOrganizationLayoutProps) => { - return {children}; + return ( + + {children} + + ); }; export default RequestOrganizationLayout; diff --git a/src/platform/src/app/(dashboard)/system/feedback/page.tsx b/src/platform/src/app/(dashboard)/system/feedback/page.tsx index 86ab757145..a403c4f4a5 100644 --- a/src/platform/src/app/(dashboard)/system/feedback/page.tsx +++ b/src/platform/src/app/(dashboard)/system/feedback/page.tsx @@ -161,18 +161,22 @@ const FeedbackListContent: React.FC = () => { { key: 'subject', label: 'Subject', + minWidth: '240px', + maxWidth: '360px', render: (_value: unknown, item: FeedbackRow) => ( -
-

{item.subject}

-

- {item.message} -

-
+

+ {item.subject} +

), }, { key: 'category', label: 'Category', + minWidth: '140px', + cellClassName: 'whitespace-nowrap', render: (value: unknown) => ( {CATEGORY_LABELS[String(value)] || String(value)} @@ -182,6 +186,8 @@ const FeedbackListContent: React.FC = () => { { key: 'status', label: 'Status', + minWidth: '120px', + cellClassName: 'whitespace-nowrap', render: (value: unknown) => ( { { key: 'rating', label: 'Rating', + minWidth: '90px', + cellClassName: 'whitespace-nowrap', render: (value: unknown) => ( {value as number}/5 @@ -204,13 +212,23 @@ const FeedbackListContent: React.FC = () => { { key: 'email', label: 'Email', + minWidth: '220px', + maxWidth: '280px', + cellClassName: 'whitespace-nowrap', render: (value: unknown) => ( - {String(value)} + + {String(value)} + ), }, { key: 'createdAt', label: 'Submitted', + minWidth: '190px', + cellClassName: 'whitespace-nowrap', render: (value: unknown) => ( {formatDateTime(String(value))} @@ -220,6 +238,8 @@ const FeedbackListContent: React.FC = () => { { key: 'actions', label: 'Actions', + minWidth: '96px', + cellClassName: 'whitespace-nowrap', render: (_value: unknown, item: FeedbackRow) => ( )} {canManageAutoRenew && subscription?.automaticRenewal && ( )} {canCancelSubscription && ( )} + +

+ Included request limits are listed in the plan cards below for quick + comparison. +

- Pricing Tiers + Available Plans

- Upgrade your API access based on your product and traffic needs. + Choose the API access tier that matches your usage and traffic + profile.

{hasPlans ? (
{plans.map(plan => { const isCurrent = plan.tier === currentTier; + const isCurrentFreePlan = isCurrent && plan.tier === 'Free'; + const isCurrentPlanPendingExpiry = + isCurrent && + currentStatus === 'cancelled' && + Boolean(accessDateText); const allowCheckout = plan.tier !== 'Free' && (!isCurrent || currentStatus === 'past_due' || - currentStatus === 'cancelled'); + (currentStatus === 'cancelled' && + !isCurrentPlanPendingExpiry) || + currentStatus === 'paused'); const isUpgrade = currentTier === 'Free' || (currentPlan ? plan.price > currentPlan.price : false); + const actionLabel = isCurrent + ? isCurrentFreePlan + ? 'Included plan' + : currentStatus === 'past_due' + ? 'Retry billing' + : currentStatus === 'cancelled' + ? isCurrentPlanPendingExpiry + ? 'Current plan' + : 'Reactivate plan' + : currentStatus === 'paused' + ? 'Resume plan' + : 'Current plan' + : allowCheckout + ? isUpgrade + ? `Upgrade to ${plan.name}` + : `Switch to ${plan.name}` + : 'Unavailable'; return ( { disabled={!allowCheckout} onClick={() => handleOpenCheckout(plan)} > - {isCurrent - ? currentStatus === 'past_due' - ? 'Retry payment' - : currentStatus === 'cancelled' - ? 'Restart subscription' - : 'Current plan' - : allowCheckout - ? isUpgrade - ? `Upgrade to ${plan.name}` - : `Choose ${plan.name}` - : 'Unavailable'} + {actionLabel} ); @@ -609,6 +781,56 @@ const SubscriptionSection: React.FC = () => {
+ {confirmationDialog && ( + { + if (!confirmationLoading) { + setPendingConfirmation(null); + } + }} + title={confirmationDialog.title} + subtitle={confirmationDialog.subtitle} + size="md" + preventBackdropClose={confirmationLoading} + showCloseButton={!confirmationLoading} + primaryAction={{ + label: confirmationLoading + ? 'Saving...' + : confirmationDialog.confirmLabel, + onClick: () => { + if (pendingConfirmation === 'enableAutoRenew') { + void handleEnableAutoRenew(); + return; + } + + if (pendingConfirmation === 'disableAutoRenew') { + void handleDisableAutoRenew(); + return; + } + + void handleCancelSubscription(); + }, + loading: confirmationLoading, + disabled: confirmationLoading, + className: confirmationDialog.confirmClassName, + }} + secondaryAction={{ + label: confirmationDialog.secondaryLabel, + onClick: () => setPendingConfirmation(null), + disabled: confirmationLoading, + variant: 'outlined', + }} + ariaLabel={confirmationDialog.title} + > +
+

+ {confirmationDialog.body} +

+
+
+ )} + { diff --git a/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx b/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx index 7a4cd8b98c..0cd102cb7e 100644 --- a/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx +++ b/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { usePathname } from 'next/navigation'; import { Rating, Star } from '@smastrom/react-rating'; import { useUser } from '@/shared/hooks/useUser'; @@ -38,6 +38,19 @@ const RATING_ITEM_STYLES = { inactiveFillColor: '#dbe4ea', }; +const SENSITIVE_QUERY_KEYS = new Set([ + 'code', + 'state', + 'token', + 'access_token', + 'refresh_token', + 'id_token', + 'session', + 'auth', + 'nonce', + 'secret', +]); + const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); @@ -64,10 +77,35 @@ const getBrowserLabel = (): string => { return userAgent.slice(0, 80); }; +const buildSanitizedPageValue = (fallbackPathname: string): string => { + if (typeof window === 'undefined') { + return fallbackPathname; + } + + const { pathname, search } = window.location; + + if (!search) { + return pathname; + } + + const searchParams = new URLSearchParams(search); + + for (const key of Array.from(searchParams.keys())) { + if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) { + searchParams.delete(key); + } + } + + const safeQuery = searchParams.toString(); + return safeQuery ? `${pathname}?${safeQuery}` : pathname; +}; + const buildFeedbackMetadata = (pathname: string) => { + const page = buildSanitizedPageValue(pathname); + if (typeof window === 'undefined') { return { - page: pathname, + page, browser: 'Unknown browser', appVersion: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0', screenResolution: 'Unknown', @@ -75,7 +113,7 @@ const buildFeedbackMetadata = (pathname: string) => { } return { - page: pathname, + page, browser: getBrowserLabel(), appVersion: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0', screenResolution: `${window.screen.width}x${window.screen.height}`, @@ -100,11 +138,6 @@ export const FeedbackLauncher: React.FC = () => { const [subject, setSubject] = useState(''); const [message, setMessage] = useState(''); - const defaultMetadata = useMemo( - () => buildFeedbackMetadata(pathname), - [pathname] - ); - const shouldHideLauncher = pathname.startsWith('/system/feedback'); useEffect(() => { @@ -162,14 +195,16 @@ export const FeedbackLauncher: React.FC = () => { setIsSubmitting(true); try { + const metadata = buildFeedbackMetadata(pathname); + await feedbackService.submitFeedback({ email: trimmedEmail, subject: trimmedSubject, message: trimmedMessage, rating, category, - platform: 'web', - metadata: defaultMetadata, + platform: 'Analytics', + metadata, }); toast.success('Feedback sent successfully'); diff --git a/src/platform/src/next-auth.d.ts b/src/platform/src/next-auth.d.ts index ba72d9d429..6f5438e3be 100644 --- a/src/platform/src/next-auth.d.ts +++ b/src/platform/src/next-auth.d.ts @@ -1,4 +1,5 @@ import type { DefaultSession, DefaultUser } from 'next-auth'; +import type { AuthMethods } from '@/shared/types/api'; declare module 'next-auth' { interface User extends DefaultUser { @@ -6,13 +7,17 @@ declare module 'next-auth' { firstName?: string; lastName?: string; accessToken?: string; + expiresAt?: string; email?: string | null; name?: string | null; image?: string | null; + authMethods?: AuthMethods; } interface Session extends DefaultSession { accessToken?: string; + expiresAt?: string; + authMethods?: AuthMethods; user: (DefaultSession['user'] & User) | null; } } @@ -23,5 +28,7 @@ declare module 'next-auth/jwt' { firstName?: string; lastName?: string; accessToken?: string; + expiresAt?: string; + authMethods?: AuthMethods; } } diff --git a/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx b/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx new file mode 100644 index 0000000000..2502618081 --- /dev/null +++ b/src/platform/src/shared/components/auth/SetPasswordPromptDialog.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { useEffect, useMemo, 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 { + setPasswordSchema, + type SetPasswordFormData, +} from '@/shared/lib/validators'; +import type { AuthMethods } from '@/shared/types/api'; + +const DISMISS_STORAGE_KEY_PREFIX = 'airqo:set-password-dismissed:'; + +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, + github: false, + linkedin: false, + microsoft: false, + twitter: false, + facebook: false, + 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)}`; +}; + +const SetPasswordPromptDialog = () => { + const { data: session, status, update } = useSession(); + const { trigger: setPassword, isMutating } = useSetPassword(); + const [isOpen, setIsOpen] = useState(false); + 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 userId = + sessionData?.user?._id?.trim() || sessionData?.user?.email?.trim(); + 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, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { + password: '', + confirmPassword: '', + }, + mode: 'onChange', + }); + + useEffect(() => { + if (status !== 'authenticated' || !dismissStorageKey || !authMethods) { + setIsOpen(false); + return; + } + + if (authMethods.password) { + sessionStorage.removeItem(dismissStorageKey); + setIsOpen(false); + return; + } + + const dismissed = sessionStorage.getItem(dismissStorageKey) === 'true'; + setIsOpen(!dismissed); + }, [authMethods, dismissStorageKey, status]); + + const closeForNow = () => { + if (dismissStorageKey) { + sessionStorage.setItem(dismissStorageKey, 'true'); + } + setIsOpen(false); + }; + + const markPasswordAsConfigured = async (): Promise => { + const nextAuthMethods: AuthMethods = { + ...DEFAULT_AUTH_METHODS, + ...authMethods, + password: true, + }; + + try { + await update({ authMethods: nextAuthMethods }); + return true; + } catch (error) { + console.warn('Failed to refresh session after setting password', error); + return false; + } finally { + if (dismissStorageKey) { + sessionStorage.removeItem(dismissStorageKey); + } + + setIsOpen(false); + } + }; + + const onSubmit = async (data: SetPasswordFormData) => { + try { + await setPassword({ + password: data.password, + confirmPassword: data.confirmPassword, + }); + + const sessionUpdated = await markPasswordAsConfigured(); + reset(); + + if (sessionUpdated) { + toast.success( + 'Password added', + 'You can now sign in with your email and password whenever you need to.' + ); + } else { + toast.warning( + 'Password saved', + 'Your password was saved, but this session could not be refreshed. Please reload if the prompt appears again.' + ); + } + } catch (error) { + const message = + error instanceof Error && error.message.trim() + ? error.message.trim() + : 'Unable to set your password right now.'; + + if (message.toLowerCase().includes('already set')) { + const sessionUpdated = await markPasswordAsConfigured(); + + if (sessionUpdated) { + toast.success( + 'Password already available', + 'Your account already has a password. You can manage it from Security settings.' + ); + } else { + toast.warning( + 'Password already available', + 'Your account already has a password, but the current session could not be refreshed.' + ); + } + return; + } + + toast.error('Unable to save your password', message); + } + }; + + if (!authMethods || authMethods.password || status !== 'authenticated') { + return null; + } + + return ( + { + void handleSubmit(onSubmit)(); + }, + loading: isMutating, + disabled: isMutating, + }} + secondaryAction={{ + label: 'Not now', + onClick: closeForNow, + disabled: isMutating, + variant: 'outlined', + }} + ariaLabel="Set account password" + > +
{ + event.preventDefault(); + void handleSubmit(onSubmit)(); + }} + > + + + + + + +

+ Use 6 to 30 characters with at least one letter and one number. +

+ +
+ ); +}; + +export default SetPasswordPromptDialog; diff --git a/src/platform/src/shared/components/auth/SocialAuthSection.tsx b/src/platform/src/shared/components/auth/SocialAuthSection.tsx index 6c082260be..cadf95bf42 100644 --- a/src/platform/src/shared/components/auth/SocialAuthSection.tsx +++ b/src/platform/src/shared/components/auth/SocialAuthSection.tsx @@ -51,22 +51,6 @@ const SOCIAL_PROVIDERS: Array<{ }, ]; -const LOCAL_TEST_HOSTNAMES = new Set([ - 'localhost', - '127.0.0.1', - '::1', - '0.0.0.0', -]); - -const shouldShowSocialAuth = (hostname: string): boolean => { - const normalizedHostname = hostname.toLowerCase(); - - return ( - normalizedHostname.includes('staging') || - LOCAL_TEST_HOSTNAMES.has(normalizedHostname) - ); -}; - export default function SocialAuthSection({ mode, disabled = false, @@ -75,12 +59,10 @@ export default function SocialAuthSection({ }: SocialAuthSectionProps) { const actionLabel = mode === 'register' ? 'Continue with' : 'Sign in with'; const redirectPath = normalizeCallbackUrl(callbackUrl) || '/user/home'; - const [isStagingDomain, setIsStagingDomain] = useState(false); const [lastUsedProvider, setLastUsedProvider] = useState(null); useEffect(() => { - setIsStagingDomain(shouldShowSocialAuth(window.location.hostname)); setLastUsedProvider(getLastUsedOAuthProvider()); }, []); @@ -115,10 +97,6 @@ export default function SocialAuthSection({ [disabled, redirectPath] ); - if (!isStagingDomain) { - return null; - } - return (
diff --git a/src/platform/src/shared/components/header/index.tsx b/src/platform/src/shared/components/header/index.tsx index 3f149cb909..1af8e4ae96 100644 --- a/src/platform/src/shared/components/header/index.tsx +++ b/src/platform/src/shared/components/header/index.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { motion } from 'framer-motion'; import { useDispatch } from 'react-redux'; +import { useSession } from 'next-auth/react'; import { cn } from '@/shared/lib/utils'; import { Card } from '@/shared/components/ui/card'; import { Button } from '@/shared/components/ui/button'; @@ -21,11 +22,15 @@ import { AqMenu01 } from '@airqo/icons-react'; export const Header: React.FC = ({ className, hideOnScroll = false, + showAuthControlsOnlyWhenAuthenticated = false, }) => { const dispatch = useDispatch(); const isVisible = useScrollVisibility(hideOnScroll); const pageTitle = usePageTitle(); const isMobile = useMediaQuery({ maxWidth: 768 }); + const { status } = useSession(); + const shouldShowAuthControls = + !showAuthControlsOnlyWhenAuthenticated || status === 'authenticated'; return ( = ({ {/* App Dropdown and Profile Dropdown */}
- {!isMobile && } + {shouldShowAuthControls && !isMobile && } - + {shouldShowAuthControls && }
diff --git a/src/platform/src/shared/components/header/types/index.ts b/src/platform/src/shared/components/header/types/index.ts index cd4a16abe6..a2d101af2b 100644 --- a/src/platform/src/shared/components/header/types/index.ts +++ b/src/platform/src/shared/components/header/types/index.ts @@ -1,6 +1,7 @@ export interface HeaderProps { className?: string; hideOnScroll?: boolean; + showAuthControlsOnlyWhenAuthenticated?: boolean; } export interface ProfileDropdownProps { diff --git a/src/platform/src/shared/hooks/index.ts b/src/platform/src/shared/hooks/index.ts index 013903fbb1..4323fbdb32 100644 --- a/src/platform/src/shared/hooks/index.ts +++ b/src/platform/src/shared/hooks/index.ts @@ -18,6 +18,7 @@ export { useRBAC } from './useRBAC'; export { useUpdateUserDetails, useUpdatePassword, + useSetPassword, useCreateOrganizationRequest, useInitiateAccountDeletion, useConfirmAccountDeletion, diff --git a/src/platform/src/shared/hooks/useUser.ts b/src/platform/src/shared/hooks/useUser.ts index d052345ad7..5216b95d94 100644 --- a/src/platform/src/shared/hooks/useUser.ts +++ b/src/platform/src/shared/hooks/useUser.ts @@ -5,6 +5,7 @@ import { userService } from '../services/userService'; import type { UpdateUserDetailsRequest, UpdatePasswordRequest, + SetPasswordRequest, CreateOrganizationRequest, GetPendingInvitationsResponse, } from '../types/api'; @@ -51,6 +52,16 @@ export const useUpdatePassword = () => { ); }; +// Set password for OAuth-authenticated users +export const useSetPassword = () => { + return useSWRMutation( + 'user/set-password', + async (key, { arg }: { arg: SetPasswordRequest }) => { + return await userService.setPassword(arg); + } + ); +}; + // Create organization request export const useCreateOrganizationRequest = () => { return useSWRMutation( diff --git a/src/platform/src/shared/layouts/MainLayout.tsx b/src/platform/src/shared/layouts/MainLayout.tsx index 41c1194cf4..fa9230a220 100644 --- a/src/platform/src/shared/layouts/MainLayout.tsx +++ b/src/platform/src/shared/layouts/MainLayout.tsx @@ -22,6 +22,7 @@ interface MainLayoutProps { className?: string; showSidebar?: boolean; showBottomNav?: boolean; + showHeaderAuthControlsOnlyWhenAuthenticated?: boolean; } export const MainLayout: React.FC = ({ @@ -29,6 +30,7 @@ export const MainLayout: React.FC = ({ className, showSidebar = true, showBottomNav = true, + showHeaderAuthControlsOnlyWhenAuthenticated = false, }) => { const sidebarCollapsed = useAppSelector(state => state.ui.sidebarCollapsed); const theme = useAppSelector(state => state.theme); @@ -48,7 +50,12 @@ export const MainLayout: React.FC = ({ )} > {/* Fixed Header */} -
+
{/* Secondary Navigation - Mobile Only */} diff --git a/src/platform/src/shared/lib/auth.ts b/src/platform/src/shared/lib/auth.ts index fc0ae68049..8bc66e3a4e 100644 --- a/src/platform/src/shared/lib/auth.ts +++ b/src/platform/src/shared/lib/auth.ts @@ -3,6 +3,7 @@ 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 type { AuthMethods } from '@/shared/types/api'; const isProduction = process.env.NODE_ENV === 'production'; const sessionCookieName = isProduction @@ -31,6 +32,7 @@ interface OAuthProfilePayload { firstName: string; lastName: string; profilePicture?: string; + authMethods?: AuthMethods; } interface OAuthProfileResponse { @@ -95,6 +97,43 @@ const isTokenInvalid = ( return isExpiredAt(expiresAt); }; +const AUTH_METHOD_KEYS = [ + 'password', + 'google', + 'github', + 'linkedin', + 'microsoft', + 'twitter', + 'facebook', + 'apple', +] as const; + +const normalizeAuthMethods = (value: unknown): AuthMethods | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as Partial< + Record<(typeof AUTH_METHOD_KEYS)[number], unknown> + >; + const hasKnownKey = AUTH_METHOD_KEYS.some(key => key in candidate); + + if (!hasKnownKey) { + return undefined; + } + + return { + password: Boolean(candidate.password), + google: Boolean(candidate.google), + github: Boolean(candidate.github), + linkedin: Boolean(candidate.linkedin), + microsoft: Boolean(candidate.microsoft), + twitter: Boolean(candidate.twitter), + facebook: Boolean(candidate.facebook), + apple: Boolean(candidate.apple), + }; +}; + export const authOptions: any = { secret: process.env.NEXTAUTH_SECRET, useSecureCookies: isProduction, @@ -135,6 +174,7 @@ export const authOptions: any = { image: profile.profilePicture, _id: profile._id, accessToken: oauthToken, + authMethods: normalizeAuthMethods(profile.authMethods), }; } @@ -167,6 +207,7 @@ export const authOptions: any = { _id: loginData._id, accessToken: normalizeOAuthAccessToken(loginData.token), expiresAt: loginData.expiresAt, + authMethods: normalizeAuthMethods(loginData.authMethods), }; } catch (error: any) { // Enhanced error handling to include status and full response data @@ -214,6 +255,7 @@ export const authOptions: any = { typeof user.expiresAt === 'string' ? user.expiresAt : undefined; token.firstName = user.firstName; token.lastName = user.lastName; + token.authMethods = normalizeAuthMethods(user.authMethods); } if (trigger === 'update' && session) { @@ -226,6 +268,10 @@ export const authOptions: any = { if (typeof session.expiresAt === 'string') { token.expiresAt = session.expiresAt; } + if (session.authMethods) { + token.authMethods = + normalizeAuthMethods(session.authMethods) || token.authMethods; + } } return token; @@ -242,6 +288,7 @@ export const authOptions: any = { typeof (token as any)?.expiresAt === 'string' ? ((token as any).expiresAt as string) : undefined; + const authMethods = normalizeAuthMethods((token as any)?.authMethods); if (isTokenInvalid(accessToken, expiresAt)) { return { user: null }; } @@ -249,12 +296,14 @@ export const authOptions: any = { // Add access token and user ID to session (session as any).accessToken = accessToken; (session as any).expiresAt = expiresAt; + (session as any).authMethods = authMethods; if (session.user) { (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 2708ba63de..b23c944e36 100644 --- a/src/platform/src/shared/lib/oauth-session.ts +++ b/src/platform/src/shared/lib/oauth-session.ts @@ -3,6 +3,7 @@ import { buildServerApiUrl, resolveApiOrigin, } from '@/shared/lib/api-routing'; +import type { AuthMethods } from '@/shared/types/api'; const OAUTH_SIGNED_OUT_FLAG = 'airqo:oauth-signed-out'; const OAUTH_FRAGMENT_TOKEN_KEY = 'token'; @@ -28,6 +29,7 @@ export interface BackendOAuthProfile { profilePicture?: string; verified?: boolean; accessToken?: string; + authMethods?: AuthMethods; } export interface BackendOAuthProfileResponse { @@ -40,6 +42,7 @@ export interface BackendOAuthProfileResponse { export interface BackendOAuthSession { expires: string; accessToken?: string; + authMethods?: AuthMethods; user: { _id: string; email: string; @@ -47,6 +50,7 @@ export interface BackendOAuthSession { lastName: string; name: string; image: string; + authMethods?: AuthMethods; }; } @@ -65,6 +69,43 @@ export const isSupportedSocialAuthProvider = ( return (SUPPORTED_SOCIAL_AUTH_PROVIDERS as readonly string[]).includes(value); }; +const AUTH_METHOD_KEYS = [ + 'password', + 'google', + 'github', + 'linkedin', + 'microsoft', + 'twitter', + 'facebook', + 'apple', +] as const; + +const normalizeAuthMethods = (value: unknown): AuthMethods | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as Partial< + Record<(typeof AUTH_METHOD_KEYS)[number], unknown> + >; + const hasKnownKey = AUTH_METHOD_KEYS.some(key => key in candidate); + + if (!hasKnownKey) { + return undefined; + } + + return { + password: Boolean(candidate.password), + google: Boolean(candidate.google), + github: Boolean(candidate.github), + linkedin: Boolean(candidate.linkedin), + microsoft: Boolean(candidate.microsoft), + twitter: Boolean(candidate.twitter), + facebook: Boolean(candidate.facebook), + apple: Boolean(candidate.apple), + }; +}; + const safeDecodeURIComponent = (value: string): string => { try { return decodeURIComponent(value); @@ -241,10 +282,12 @@ export const buildSessionFromProfile = ( const normalizedAccessToken = profile.accessToken ? normalizeOAuthAccessToken(profile.accessToken) || undefined : undefined; + const authMethods = normalizeAuthMethods(profile.authMethods); return { expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), accessToken: normalizedAccessToken, + authMethods, user: { _id: profile._id, email: profile.email, @@ -252,6 +295,7 @@ export const buildSessionFromProfile = ( lastName: profile.lastName, name: fullName || profile.email, image: profile.profilePicture ?? '', + authMethods, }, }; }; @@ -295,6 +339,7 @@ export const verifyBackendOAuthSession = : payload.accessToken ? normalizeOAuthAccessToken(payload.accessToken) || undefined : undefined, + authMethods: normalizeAuthMethods(payload.data.authMethods), }; } catch (error) { const errorName = (error as { name?: string })?.name; diff --git a/src/platform/src/shared/lib/validators.ts b/src/platform/src/shared/lib/validators.ts index fdbd44be84..cf5c5be9d1 100644 --- a/src/platform/src/shared/lib/validators.ts +++ b/src/platform/src/shared/lib/validators.ts @@ -38,6 +38,23 @@ export const resetPwdSchema = z path: ['confirmPassword'], }); +export const setPasswordSchema = z + .object({ + password: z + .string() + .min(6, 'Password must be at least 6 characters') + .max(30, 'Password must be 30 characters or less') + .regex( + /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@#?!$%^&*.,]+$/, + 'Password must include at least one letter and one number' + ), + confirmPassword: z.string(), + }) + .refine(data => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); + export const profileSchema = z.object({ firstName: z.string().min(1, 'First name is required'), lastName: z.string().min(1, 'Last name is required'), @@ -130,6 +147,7 @@ export type LoginFormData = z.infer; export type RegisterFormData = z.infer; export type ForgotPwdFormData = z.infer; export type ResetPwdFormData = z.infer; +export type SetPasswordFormData = z.infer; export type ProfileFormData = z.infer; export type SecurityFormData = z.infer; export type OrganizationRequestFormData = z.infer< diff --git a/src/platform/src/shared/providers/auth-provider.tsx b/src/platform/src/shared/providers/auth-provider.tsx index 3f29d926c5..75ec673a8c 100644 --- a/src/platform/src/shared/providers/auth-provider.tsx +++ b/src/platform/src/shared/providers/auth-provider.tsx @@ -21,6 +21,7 @@ import { toast } from '@/shared/components/ui/toast'; import logger from '@/shared/lib/logger'; import { SWRProvider } from '@/shared/providers/swr-provider'; import { QueryProvider } from '@/shared/providers/query-provider'; +import SetPasswordPromptDialog from '@/shared/components/auth/SetPasswordPromptDialog'; import { runClientCacheMaintenance } from '@/shared/lib/clientCache'; import { normalizeCallbackUrl, @@ -150,12 +151,17 @@ const publicRoutes = [ '/user/forgotPwd/reset', '/user/delete/confirm', // covers /user/delete/confirm/[token] '/org-invite', // Public invitation acceptance page + '/request-organization', ]; const isPublicAuthRoute = (pathname: string): boolean => publicRoutes.some(route => pathname.startsWith(route)) || /^\/org\/[^/]+\/(login|register)$/.test(pathname); +const isAuthenticatedAccessiblePublicRoute = (pathname: string): boolean => + pathname.startsWith('/org-invite') || + pathname.startsWith('/request-organization'); + const UNAUTHORIZED_WINDOW_MS = 30000; const UNAUTHORIZED_THRESHOLD = 3; const ACCOUNT_DELETION_TTL_MS = 5 * 60 * 1000; @@ -580,7 +586,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) { return; } - if (pathname.startsWith('/org-invite')) { + if (isAuthenticatedAccessiblePublicRoute(pathname)) { return; } @@ -650,6 +656,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) { return ( {children} + ); } diff --git a/src/platform/src/shared/services/subscriptionService.ts b/src/platform/src/shared/services/subscriptionService.ts index 2cd4712bb1..07a34495df 100644 --- a/src/platform/src/shared/services/subscriptionService.ts +++ b/src/platform/src/shared/services/subscriptionService.ts @@ -264,10 +264,14 @@ const normalizeStatus = (status?: string): NormalizedSubscriptionStatus => { return 'past_due'; } - if (normalized === 'cancelled') { + if (normalized === 'cancelled' || normalized === 'canceled') { return 'cancelled'; } + if (normalized === 'paused') { + return 'paused'; + } + return 'inactive'; }; diff --git a/src/platform/src/shared/services/userService.ts b/src/platform/src/shared/services/userService.ts index ea6e387dbe..9c643dfb3c 100644 --- a/src/platform/src/shared/services/userService.ts +++ b/src/platform/src/shared/services/userService.ts @@ -17,6 +17,8 @@ import type { UpdateUserDetailsResponse, UpdatePasswordRequest, UpdatePasswordResponse, + SetPasswordRequest, + SetPasswordResponse, VerifyEmailResponse, CreateOrganizationRequest, CreateOrganizationResponse, @@ -231,6 +233,43 @@ export class UserService { return data as UpdatePasswordResponse; } + // Set password for OAuth-authenticated users - authenticated endpoint + async setPassword( + passwordData: SetPasswordRequest + ): Promise { + await this.ensureAuthenticated(); + const response = await this.authenticatedClient.post< + SetPasswordResponse | ApiErrorResponse + >('/users/setPassword?tenant=airqo', passwordData); + const data = response.data; + + if ('success' in data && !data.success) { + const candidateErrors = (data as { errors?: unknown }).errors; + const errorMessage = Array.isArray(candidateErrors) + ? candidateErrors + .map(error => { + if (!error || typeof error !== 'object') { + return ''; + } + + const message = (error as { message?: unknown }).message; + return typeof message === 'string' ? message.trim() : ''; + }) + .filter(Boolean) + .join('; ') + : candidateErrors && typeof candidateErrors === 'object' + ? typeof (candidateErrors as { message?: unknown }).message === + 'string' + ? (candidateErrors as { message?: string }).message?.trim() + : '' + : ''; + + throw new Error(errorMessage || data.message || 'Failed to set password'); + } + + return data as SetPasswordResponse; + } + // Verify email - open endpoint async verifyEmail( userId: string, diff --git a/src/platform/src/shared/types/api.ts b/src/platform/src/shared/types/api.ts index 920115a299..b596df293c 100644 --- a/src/platform/src/shared/types/api.ts +++ b/src/platform/src/shared/types/api.ts @@ -5,6 +5,17 @@ export interface LoginRequest { password: string; } +export interface AuthMethods { + password: boolean; + google: boolean; + github: boolean; + linkedin: boolean; + microsoft: boolean; + twitter: boolean; + facebook: boolean; + apple: boolean; +} + export interface LoginResponse { _id: string; token: string; @@ -27,6 +38,7 @@ export interface LoginResponse { rateLimit: unknown; lastLogin: string; expiresAt: string; + authMethods?: AuthMethods; } // Roles and Permissions Types for Admin Management @@ -250,6 +262,16 @@ export interface ResetPasswordResponse { user: Record; } +export interface SetPasswordRequest { + password: string; + confirmPassword: string; +} + +export interface SetPasswordResponse { + success: boolean; + message: string; +} + export interface MaintenanceItem { _id: string; product: string; @@ -304,6 +326,7 @@ export interface User { networks: Network[]; clients: Client[]; permissions: RolePermission[]; + authMethods?: AuthMethods; } export interface Network { @@ -1633,7 +1656,13 @@ export interface SubscriptionPlan { export interface UserSubscription { tier: SubscriptionTier; - status: 'active' | 'inactive' | 'past_due' | 'cancelled' | 'trialing'; + status: + | 'active' + | 'inactive' + | 'past_due' + | 'cancelled' + | 'trialing' + | 'paused'; nextBillingDate?: string | null; lastRenewalDate?: string | null; automaticRenewal?: boolean; @@ -1678,7 +1707,13 @@ export interface GetSubscriptionResponse { success: boolean; message: string; data?: { - status: 'active' | 'inactive' | 'past_due' | 'cancelled' | 'trialing'; + status: + | 'active' + | 'inactive' + | 'past_due' + | 'cancelled' + | 'trialing' + | 'paused'; tier: SubscriptionTier; nextBillingDate?: string | null; }; diff --git a/src/platform/src/shared/types/global.d.ts b/src/platform/src/shared/types/global.d.ts index 2f7caab651..d16209e066 100644 --- a/src/platform/src/shared/types/global.d.ts +++ b/src/platform/src/shared/types/global.d.ts @@ -3,15 +3,48 @@ 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; + }; } }