- Selected Plan
+ Plan To Activate
{plan?.name || '--'}
@@ -78,7 +79,7 @@ const CheckoutDialog: React.FC = ({
{plan && (
- Included with {plan.name}
+ Plan highlights
{plan.features.slice(0, 4).map(feature => (
diff --git a/src/platform/src/modules/billing/components/SubscriptionSection.tsx b/src/platform/src/modules/billing/components/SubscriptionSection.tsx
index 178ab38f87..b1bcc4e9b9 100644
--- a/src/platform/src/modules/billing/components/SubscriptionSection.tsx
+++ b/src/platform/src/modules/billing/components/SubscriptionSection.tsx
@@ -10,6 +10,7 @@ import React, {
import { useSession } from 'next-auth/react';
import { AqCheck } from '@airqo/icons-react';
import { 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';
import { subscriptionService } from '@/shared/services/subscriptionService';
@@ -26,6 +27,9 @@ const BILLING_SERVICE_UNAVAILABLE_MESSAGE =
const CHECKOUT_STATUS_POLL_INTERVAL_MS = 2000;
const CHECKOUT_STATUS_MAX_ATTEMPTS = 6;
+type ConfirmationAction = 'enableAutoRenew' | 'disableAutoRenew' | 'cancel';
+type RunningAction = 'checkout' | ConfirmationAction | null;
+
interface ExtendedSessionUser {
_id?: string;
}
@@ -37,26 +41,19 @@ const statusBadgeStyles: Record = {
trialing: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200',
past_due:
'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
- cancelled: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200',
+ cancelled:
+ 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200',
+ paused:
+ 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-200',
};
-const formatRateLimitSummary = (
- rateLimits?: UserSubscription['apiRateLimits']
-) => {
- if (!rateLimits) {
- return 'Limits unavailable';
- }
-
- return [
- `${rateLimits.hourlyLimit.toLocaleString()}/hr`,
- `${rateLimits.dailyLimit.toLocaleString()}/day`,
- typeof rateLimits.weeklyLimit === 'number'
- ? `${rateLimits.weeklyLimit.toLocaleString()}/week`
- : null,
- `${rateLimits.monthlyLimit.toLocaleString()}/month`,
- ]
- .filter(Boolean)
- .join(' | ');
+const statusLabels: Record = {
+ active: 'Active',
+ inactive: 'Inactive',
+ trialing: 'Trialing',
+ past_due: 'Past due',
+ cancelled: 'Ended',
+ paused: 'Paused',
};
const getBillingErrorLogMessage = (error: unknown): string => {
@@ -90,15 +87,62 @@ const SubscriptionSection: React.FC = () => {
const [selectedPlan, setSelectedPlan] = useState(
null
);
- const [runningAction, setRunningAction] = useState<
- 'checkout' | 'enableAutoRenew' | 'disableAutoRenew' | 'cancel' | null
- >(null);
+ const [runningAction, setRunningAction] = useState(null);
+ const [pendingConfirmation, setPendingConfirmation] =
+ useState(null);
const isMountedRef = useRef(true);
const userId =
(session?.user as ExtendedSessionUser | null)?._id?.trim() || '';
const currentTier: SubscriptionTier = subscription?.tier || 'Free';
const currentStatus = subscription?.status || 'inactive';
+ const accessDateText = useMemo(() => {
+ if (!subscription?.nextBillingDate) {
+ return null;
+ }
+
+ return formatDate(subscription.nextBillingDate, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ }, [subscription?.nextBillingDate]);
+ const currentStatusLabel = useMemo(() => {
+ if (currentStatus === 'cancelled' && accessDateText) {
+ return 'Scheduled to end';
+ }
+
+ return statusLabels[currentStatus] || currentStatus.replace('_', ' ');
+ }, [accessDateText, currentStatus]);
+ const summaryDateLabel = useMemo(() => {
+ if (currentTier === 'Free' && currentStatus !== 'cancelled') {
+ return 'Plan status';
+ }
+
+ if (currentStatus === 'cancelled' && !accessDateText) {
+ return 'Plan status';
+ }
+
+ return subscription?.automaticRenewal
+ ? 'Next renewal date'
+ : 'Access through';
+ }, [
+ accessDateText,
+ currentStatus,
+ currentTier,
+ subscription?.automaticRenewal,
+ ]);
+ const summaryDateValue = useMemo(() => {
+ if (currentTier === 'Free' && currentStatus !== 'cancelled') {
+ return 'Free tier';
+ }
+
+ if (currentStatus === 'cancelled' && !accessDateText) {
+ return 'Ended';
+ }
+
+ return accessDateText || 'Not scheduled';
+ }, [accessDateText, currentStatus, currentTier]);
const refreshData = useCallback(async () => {
setLoading(true);
@@ -149,10 +193,115 @@ const SubscriptionSection: React.FC = () => {
() => plans.find(plan => plan.tier === currentTier),
[plans, currentTier]
);
+ 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',
+ eyebrow: 'Action required',
+ title: 'We could not complete your latest renewal',
+ message:
+ 'Retry billing or update your payment setup to keep API access active without interruption.',
+ };
+ }
+
+ 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',
+ eyebrow: 'Renewal off',
+ title: `Paid access remains available through ${accessDateText}`,
+ message:
+ 'Automatic renewal is off for this subscription. Resume renewal before that date to continue service without interruption.',
+ };
+ }
+
+ return {
+ tone: 'border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-800 dark:bg-slate-900/40 dark:text-slate-200',
+ eyebrow: 'Subscription ended',
+ title: 'This paid plan is no longer active',
+ message:
+ 'Your account is currently on the Free tier. Choose a paid plan below whenever you need higher API capacity again.',
+ };
+ }
+
+ 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',
+ eyebrow: 'Subscription paused',
+ title: 'Billing is paused for this plan',
+ message:
+ 'Resume renewal when you are ready to restart scheduled billing and higher-rate access.',
+ };
+ }
+
+ 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',
+ eyebrow: 'Renewal off',
+ title: accessDateText
+ ? `Your plan stays active through ${accessDateText}`
+ : 'Automatic renewal is turned off',
+ message: accessDateText
+ ? 'Resume renewal before that date to continue service without interruption.'
+ : 'This plan will remain available until the current access period ends and will not renew automatically.',
+ };
+ }
+
+ return null;
+ }, [
+ accessDateText,
+ currentStatus,
+ currentTier,
+ subscription?.automaticRenewal,
+ ]);
+ const confirmationDialog = useMemo(() => {
+ if (!pendingConfirmation) {
+ return null;
+ }
+
+ const accessWindow =
+ accessDateText || 'the end of the current billing period';
+
+ if (pendingConfirmation === 'enableAutoRenew') {
+ return {
+ title: 'Resume automatic renewal?',
+ subtitle: 'Keep this plan renewing on its scheduled billing cycle.',
+ body: 'We will update your billing settings so this subscription renews automatically on the next scheduled billing date.',
+ confirmLabel: 'Resume renewal',
+ secondaryLabel: 'Not now',
+ };
+ }
+
+ if (pendingConfirmation === 'disableAutoRenew') {
+ return {
+ title: 'Turn off automatic renewal?',
+ subtitle:
+ 'Your current plan remains available until the active access period ends.',
+ body: `After ${accessWindow}, this subscription will stop renewing automatically unless you turn renewal back on first.`,
+ confirmLabel: 'Turn off renewal',
+ secondaryLabel: 'Keep renewal on',
+ };
+ }
+
+ return {
+ title: 'Cancel this subscription?',
+ subtitle:
+ 'We will send the cancellation request to the billing provider and refresh the latest status here.',
+ body: 'Use this option only if you want to end the current subscription record. If you only want to stop the next renewal, choose Turn off renewal instead.',
+ confirmLabel: 'Cancel subscription',
+ secondaryLabel: 'Keep subscription',
+ confirmClassName:
+ 'text-sm bg-rose-600 hover:bg-rose-700 focus:ring-rose-600 text-white disabled:opacity-50',
+ };
+ }, [accessDateText, pendingConfirmation]);
+ const confirmationLoading =
+ pendingConfirmation !== null && runningAction === pendingConfirmation;
const hasPlans = plans.length > 0;
const canManageAutoRenew =
currentTier !== 'Free' &&
- (currentStatus === 'active' || currentStatus === 'trialing');
+ (currentStatus === 'active' ||
+ currentStatus === 'trialing' ||
+ currentStatus === 'cancelled');
const canCancelSubscription =
currentTier !== 'Free' &&
currentStatus !== 'inactive' &&
@@ -298,7 +447,11 @@ const SubscriptionSection: React.FC = () => {
}
await refreshData();
- toast.success(payload.message || 'Automatic renewal enabled');
+ toast.success(
+ 'Renewal resumed',
+ payload.message ||
+ 'This plan will continue renewing automatically on its scheduled billing date.'
+ );
} catch (error) {
console.error(`Auto-renewal error: ${getBillingErrorLogMessage(error)}`);
toast.error(
@@ -308,18 +461,11 @@ const SubscriptionSection: React.FC = () => {
);
} finally {
setRunningAction(null);
+ setPendingConfirmation(null);
}
};
const handleDisableAutoRenew = async () => {
- const confirmed = window.confirm(
- 'Disable auto-renewal? Your current plan will stay active until the next billing date, but it will not renew automatically.'
- );
-
- if (!confirmed) {
- return;
- }
-
try {
setRunningAction('disableAutoRenew');
@@ -337,7 +483,11 @@ const SubscriptionSection: React.FC = () => {
}
await refreshData();
- toast.success(payload.message || 'Automatic renewal disabled');
+ toast.success(
+ 'Renewal turned off',
+ payload.message ||
+ 'The current plan will remain available until the active period ends.'
+ );
} catch (error) {
console.error(
`Disable auto-renewal error: ${getBillingErrorLogMessage(error)}`
@@ -349,18 +499,11 @@ const SubscriptionSection: React.FC = () => {
);
} finally {
setRunningAction(null);
+ setPendingConfirmation(null);
}
};
const handleCancelSubscription = async () => {
- const confirmed = window.confirm(
- 'Cancel your subscription now? This moves your account back to the Free tier immediately.'
- );
-
- if (!confirmed) {
- return;
- }
-
try {
setRunningAction('cancel');
@@ -376,7 +519,11 @@ const SubscriptionSection: React.FC = () => {
}
await refreshData();
- toast.success(payload.message || 'Subscription cancelled');
+ toast.success(
+ 'Subscription updated',
+ payload.message ||
+ 'The latest billing status has been applied to your account.'
+ );
} catch (error) {
console.error(
`Cancel subscription error: ${getBillingErrorLogMessage(error)}`
@@ -386,6 +533,7 @@ const SubscriptionSection: React.FC = () => {
);
} finally {
setRunningAction(null);
+ setPendingConfirmation(null);
}
};
@@ -400,8 +548,8 @@ const SubscriptionSection: React.FC = () => {
return (
<>
-
-
+
+
@@ -421,33 +569,46 @@ const SubscriptionSection: React.FC = () => {
- {currentStatus.replace('_', ' ')}
+ {currentStatusLabel}
{subscription?.automaticRenewal && (
- Auto-renew enabled
+ Renewal on
+
+ )}
+ {!subscription?.automaticRenewal && currentTier !== 'Free' && (
+
+ Renewal off
)}
-
-
+ {statusNotice && (
+
+
+ {statusNotice.eyebrow}
+
+
+ {statusNotice.title}
+
+
{statusNotice.message}
+
+ )}
+
+
+
- Next Billing Date
+ {summaryDateLabel}
- {subscription?.nextBillingDate
- ? formatDate(subscription.nextBillingDate, {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- })
- : 'Not scheduled'}
+ {summaryDateValue}
-
+
Last Renewal
@@ -461,74 +622,95 @@ const SubscriptionSection: React.FC = () => {
: 'No renewals yet'}
-
-
-
- Current Limits
-
-
- {formatRateLimitSummary(subscription?.apiRateLimits)}
-
-
{canManageAutoRenew && !subscription?.automaticRenewal && (
)}
{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"
+ >
+
+
+ );
+};
+
+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;
+ };
}
}