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