diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java index 2354da7fc..05255ef07 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java @@ -106,15 +106,6 @@ public class AdminLicenseController { if (license != License.NORMAL) { GeneralUtils.saveKeyToSettings("premium.enabled", true); // Enable premium features - - // Save maxUsers from license metadata - Integer maxUsers = applicationProperties.getPremium().getMaxUsers(); - if (maxUsers != null) { - GeneralUtils.saveKeyToSettings("premium.maxUsers", maxUsers); - } - - log.info( - "Premium features enabled: type={}, maxUsers={}", license.name(), maxUsers); } else { GeneralUtils.saveKeyToSettings("premium.enabled", false); log.info("License key is not valid for premium features: type={}", license.name()); diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx index a02497179..b865e4f26 100644 --- a/frontend/src/proprietary/components/AppProviders.tsx +++ b/frontend/src/proprietary/components/AppProviders.tsx @@ -1,5 +1,6 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders"; import { AuthProvider } from "@app/auth/UseSession"; +import { LicenseProvider } from "@app/contexts/LicenseContext"; import { CheckoutProvider } from "@app/contexts/CheckoutContext"; import UpgradeBanner from "@app/components/shared/UpgradeBanner"; @@ -10,10 +11,12 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide appConfigProviderProps={appConfigProviderProps} > - - - {children} - + + + + {children} + + ); diff --git a/frontend/src/proprietary/components/shared/StripeCheckout.tsx b/frontend/src/proprietary/components/shared/StripeCheckout.tsx index 87729d9ec..6018dcafc 100644 --- a/frontend/src/proprietary/components/shared/StripeCheckout.tsx +++ b/frontend/src/proprietary/components/shared/StripeCheckout.tsx @@ -35,6 +35,10 @@ interface StripeCheckoutProps { onSuccess?: (sessionId: string) => void; onError?: (error: string) => void; onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void; + hostedCheckoutSuccess?: { + isUpgrade: boolean; + licenseKey?: string; + } | null; } type CheckoutState = { @@ -52,6 +56,7 @@ const StripeCheckout: React.FC = ({ onSuccess, onError, onLicenseActivated, + hostedCheckoutSuccess, }) => { const { t } = useTranslation(); const [state, setState] = useState({ status: 'idle' }); @@ -238,6 +243,25 @@ const StripeCheckout: React.FC = ({ }; }, []); + // Handle hosted checkout success - open directly to success state + useEffect(() => { + if (opened && hostedCheckoutSuccess) { + console.log('Opening modal to success state for hosted checkout return'); + + // Set appropriate state based on upgrade vs new subscription + if (hostedCheckoutSuccess.isUpgrade) { + setCurrentLicenseKey('existing'); // Flag to indicate upgrade + setPollingStatus('ready'); + } else if (hostedCheckoutSuccess.licenseKey) { + setLicenseKey(hostedCheckoutSuccess.licenseKey); + setPollingStatus('ready'); + } + + // Set to success state to show success UI + setState({ status: 'success' }); + } + }, [opened, hostedCheckoutSuccess]); + // Initialize checkout when modal opens or period changes useEffect(() => { // Don't reset if we're showing success state (license key) @@ -245,12 +269,17 @@ const StripeCheckout: React.FC = ({ return; } + // Skip initialization if opening for hosted checkout success + if (hostedCheckoutSuccess) { + return; + } + if (opened && state.status === 'idle') { createCheckoutSession(); } else if (!opened) { setState({ status: 'idle' }); } - }, [opened, selectedPeriod, state.status]); + }, [opened, selectedPeriod, state.status, hostedCheckoutSuccess]); const renderContent = () => { // Check if Stripe is configured diff --git a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx index 8a5355fe8..b1067bd12 100644 --- a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx +++ b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx @@ -3,7 +3,8 @@ import { Group, Text, Button, ActionIcon, Paper } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useAuth } from '@app/auth/UseSession'; import { useCheckout } from '@app/contexts/CheckoutContext'; -import licenseService, { mapLicenseToTier } from '@app/services/licenseService'; +import { useLicense } from '@app/contexts/LicenseContext'; +import { mapLicenseToTier } from '@app/services/licenseService'; import LocalIcon from '@app/components/shared/LocalIcon'; /** @@ -23,48 +24,40 @@ const UpgradeBanner: React.FC = () => { const { t } = useTranslation(); const { user } = useAuth(); const { openCheckout } = useCheckout(); + const { licenseInfo, loading: licenseLoading } = useLicense(); const [isVisible, setIsVisible] = useState(false); - const [isLoading, setIsLoading] = useState(true); // Check if user should see the banner useEffect(() => { - const checkVisibility = async () => { - try { - // Don't show if not logged in - if (!user) { - setIsVisible(false); - setIsLoading(false); - return; - } + // Don't show if not logged in + if (!user) { + setIsVisible(false); + return; + } - // Check if banner was dismissed - const dismissed = localStorage.getItem('upgradeBannerDismissed'); - if (dismissed === 'true') { - setIsVisible(false); - setIsLoading(false); - return; - } + // Don't show while license is loading + if (licenseLoading) { + return; + } - // Check license status - const licenseInfo = await licenseService.getLicenseInfo(); - const tier = mapLicenseToTier(licenseInfo); + // Check if banner was dismissed + const dismissed = localStorage.getItem('upgradeBannerDismissed'); + if (dismissed === 'true') { + setIsVisible(false); + return; + } - // Show banner only for free tier users - if (tier === 'free' || tier === null) { - setIsVisible(true); - } else { - setIsVisible(false); - } - } catch (error) { - console.error('Error checking upgrade banner visibility:', error); - setIsVisible(false); - } finally { - setIsLoading(false); - } - }; + // Check license status from global context + const tier = mapLicenseToTier(licenseInfo); - checkVisibility(); - }, [user]); + // Show banner only for free tier users + if (tier === 'free' || tier === null) { + setIsVisible(true); + } else { + // Auto-hide banner if user upgrades + setIsVisible(false); + } + }, [user, licenseInfo, licenseLoading]); // Handle dismiss const handleDismiss = () => { @@ -85,7 +78,7 @@ const UpgradeBanner: React.FC = () => { }; // Don't render anything if loading or not visible - if (isLoading || !isVisible) { + if (licenseLoading || !isVisible) { return null; } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index 86d4dac54..b8a7644e3 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -2,8 +2,9 @@ import React, { useState, useCallback, useEffect } from 'react'; import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePlans } from '@app/hooks/usePlans'; -import licenseService, { PlanTierGroup, LicenseInfo } from '@app/services/licenseService'; +import { PlanTierGroup } from '@app/services/licenseService'; import { useCheckout } from '@app/contexts/CheckoutContext'; +import { useLicense } from '@app/contexts/LicenseContext'; import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection'; import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection'; import { useAppConfig } from '@app/contexts/AppConfigContext'; @@ -25,9 +26,9 @@ const AdminPlanSection: React.FC = () => { const { t } = useTranslation(); const { config } = useAppConfig(); const { openCheckout } = useCheckout(); + const { licenseInfo } = useLicense(); const [currency, setCurrency] = useState('gbp'); const [useStaticVersion, setUseStaticVersion] = useState(false); - const [currentLicenseInfo, setCurrentLicenseInfo] = useState(null); const [showLicenseKey, setShowLicenseKey] = useState(false); const { plans, loading, error, refetch } = usePlans(currency); @@ -45,32 +46,17 @@ const AdminPlanSection: React.FC = () => { sectionName: 'premium', }); - // Check if we should use static version and fetch license info + // Check if we should use static version useEffect(() => { - const fetchLicenseInfo = async () => { - try { - // Fetch license info from backend endpoint - try { - const backendLicenseInfo = await licenseService.getLicenseInfo(); - setCurrentLicenseInfo(backendLicenseInfo); - } catch (licenseErr: any) { - console.error('Failed to fetch backend license info:', licenseErr); - } - } catch (err) { - console.error('Failed to fetch license info:', err); - } - }; - // Check if Stripe is configured const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; if (!stripeKey || error) { setUseStaticVersion(true); } - fetchLicenseInfo(); // Fetch premium settings fetchPremiumSettings(); - }, [error, config]); + }, [error, config, fetchPremiumSettings]); const handleSaveLicense = async () => { try { @@ -106,17 +92,9 @@ const AdminPlanSection: React.FC = () => { openCheckout(planGroup.tier, { currency, onSuccess: () => { - // Refetch plans and license info after successful payment + // Refetch plans after successful payment + // License context will auto-update refetch(); - const fetchLicenseInfo = async () => { - try { - const backendLicenseInfo = await licenseService.getLicenseInfo(); - setCurrentLicenseInfo(backendLicenseInfo); - } catch (err) { - console.error('Failed to refetch license info:', err); - } - }; - fetchLicenseInfo(); }, }); }, @@ -125,7 +103,7 @@ const AdminPlanSection: React.FC = () => { // Show static version if Stripe is not configured or there's an error if (useStaticVersion) { - return ; + return ; } // Early returns after all hooks are called @@ -139,7 +117,7 @@ const AdminPlanSection: React.FC = () => { if (error) { // Fallback to static version on error - return ; + return ; } if (!plans || plans.length === 0) { @@ -171,7 +149,7 @@ const AdminPlanSection: React.FC = () => { {/* Manage Subscription Button - Only show if user has active license */} - {currentLicenseInfo?.licenseKey && ( + {licenseInfo?.licenseKey && ( {t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')} @@ -184,7 +162,7 @@ const AdminPlanSection: React.FC = () => { diff --git a/frontend/src/proprietary/contexts/CheckoutContext.tsx b/frontend/src/proprietary/contexts/CheckoutContext.tsx index 47666a645..2ec1f35d7 100644 --- a/frontend/src/proprietary/contexts/CheckoutContext.tsx +++ b/frontend/src/proprietary/contexts/CheckoutContext.tsx @@ -6,6 +6,7 @@ import StripeCheckout from '@app/components/shared/StripeCheckout'; import { userManagementService } from '@app/services/userManagementService'; import { alert } from '@app/components/toast'; import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils'; +import { useLicense } from '@app/contexts/LicenseContext'; export interface CheckoutOptions { minimumSeats?: number; // Override calculated seats for enterprise @@ -36,12 +37,17 @@ export const CheckoutProvider: React.FC = ({ defaultCurrency = 'gbp' }) => { const { t } = useTranslation(); + const { refetchLicense } = useLicense(); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectedPlanGroup, setSelectedPlanGroup] = useState(null); const [minimumSeats, setMinimumSeats] = useState(1); const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency); const [currentOptions, setCurrentOptions] = useState({}); + const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{ + isUpgrade: boolean; + licenseKey?: string; + } | null>(null); // Load plans with current currency const { plans, refetch: refetchPlans } = usePlans(currentCurrency); @@ -75,11 +81,29 @@ export const CheckoutProvider: React.FC = ({ const activation = await resyncExistingLicense(); if (activation.success) { - alert({ - alertType: 'success', - title: t('payment.upgradeSuccess'), - }); - refetchPlans(); // Refresh plans to show updated subscription + console.log('License synced successfully, refreshing license context'); + + // Refresh global license context + await refetchLicense(); + await refetchPlans(); + + // Determine tier from license type + const tier = activation.licenseType === 'ENTERPRISE' ? 'enterprise' : 'server'; + const planGroups = licenseService.groupPlansByTier(plans); + const planGroup = planGroups.find(pg => pg.tier === tier); + + if (planGroup) { + // Reopen modal to show success + setSelectedPlanGroup(planGroup); + setHostedCheckoutSuccess({ isUpgrade: true }); + setIsOpen(true); + } else { + // Fallback to toast if plan group not found + alert({ + alertType: 'success', + title: t('payment.upgradeSuccess'), + }); + } } else { console.error('Failed to sync license after upgrade:', activation.error); alert({ @@ -90,10 +114,6 @@ export const CheckoutProvider: React.FC = ({ } else { // NEW SUBSCRIPTION: Poll for license key console.log('New subscription - polling for license key'); - alert({ - alertType: 'success', - title: t('payment.paymentSuccess'), - }); try { const installationId = await licenseService.getInstallationId(); @@ -108,11 +128,31 @@ export const CheckoutProvider: React.FC = ({ if (activation.success) { console.log(`License key activated: ${activation.licenseType}`); - alert({ - alertType: 'success', - title: t('payment.licenseActivated'), - }); - refetchPlans(); // Refresh plans to show updated subscription + + // Refresh global license context + await refetchLicense(); + await refetchPlans(); + + // Determine tier from license type + const tier = activation.licenseType === 'ENTERPRISE' ? 'enterprise' : 'server'; + const planGroups = licenseService.groupPlansByTier(plans); + const planGroup = planGroups.find(pg => pg.tier === tier); + + if (planGroup) { + // Reopen modal to show success with license key + setSelectedPlanGroup(planGroup); + setHostedCheckoutSuccess({ + isUpgrade: false, + licenseKey: result.licenseKey + }); + setIsOpen(true); + } else { + // Fallback to toast if plan group not found + alert({ + alertType: 'success', + title: t('payment.licenseActivated'), + }); + } } else { console.error('Failed to save license key:', activation.error); alert({ @@ -155,7 +195,7 @@ export const CheckoutProvider: React.FC = ({ }; handleCheckoutReturn(); - }, [t, refetchPlans]); + }, [t, refetchPlans, refetchLicense, plans]); const openCheckout = useCallback( async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => { @@ -233,10 +273,12 @@ export const CheckoutProvider: React.FC = ({ setIsOpen(false); setSelectedPlanGroup(null); setCurrentOptions({}); + setHostedCheckoutSuccess(null); - // Refetch plans after modal closes to update subscription display + // Refetch plans and license after modal closes to update subscription display refetchPlans(); - }, [refetchPlans]); + refetchLicense(); + }, [refetchPlans, refetchLicense]); const handlePaymentSuccess = useCallback( (sessionId: string) => { @@ -286,6 +328,7 @@ export const CheckoutProvider: React.FC = ({ onSuccess={handlePaymentSuccess} onError={handlePaymentError} onLicenseActivated={handleLicenseActivated} + hostedCheckoutSuccess={hostedCheckoutSuccess} /> )} diff --git a/frontend/src/proprietary/contexts/LicenseContext.tsx b/frontend/src/proprietary/contexts/LicenseContext.tsx new file mode 100644 index 000000000..9f40428d6 --- /dev/null +++ b/frontend/src/proprietary/contexts/LicenseContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; +import licenseService, { LicenseInfo } from '@app/services/licenseService'; + +interface LicenseContextValue { + licenseInfo: LicenseInfo | null; + loading: boolean; + error: string | null; + refetchLicense: () => Promise; +} + +const LicenseContext = createContext(undefined); + +interface LicenseProviderProps { + children: ReactNode; +} + +export const LicenseProvider: React.FC = ({ children }) => { + const [licenseInfo, setLicenseInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetchLicense = useCallback(async () => { + try { + setLoading(true); + setError(null); + const info = await licenseService.getLicenseInfo(); + setLicenseInfo(info); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch license info'; + console.error('Error fetching license info:', errorMessage); + setError(errorMessage); + setLicenseInfo(null); + } finally { + setLoading(false); + } + }, []); + + // Fetch license info on mount + useEffect(() => { + refetchLicense(); + }, [refetchLicense]); + + const contextValue: LicenseContextValue = { + licenseInfo, + loading, + error, + refetchLicense, + }; + + return ( + + {children} + + ); +}; + +export const useLicense = (): LicenseContextValue => { + const context = useContext(LicenseContext); + if (!context) { + throw new Error('useLicense must be used within LicenseProvider'); + } + return context; +};