global license context added to support checkout context

This commit is contained in:
Connor Yoh 2025-11-19 12:30:59 +00:00
parent afa71c002b
commit 83a212ec16
7 changed files with 200 additions and 100 deletions

View File

@ -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());

View File

@ -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}
>
<AuthProvider>
<CheckoutProvider>
<UpgradeBanner />
{children}
</CheckoutProvider>
<LicenseProvider>
<CheckoutProvider>
<UpgradeBanner />
{children}
</CheckoutProvider>
</LicenseProvider>
</AuthProvider>
</CoreAppProviders>
);

View File

@ -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<StripeCheckoutProps> = ({
onSuccess,
onError,
onLicenseActivated,
hostedCheckoutSuccess,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
@ -238,6 +243,25 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
};
}, []);
// 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<StripeCheckoutProps> = ({
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

View File

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

View File

@ -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<string>('gbp');
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [currentLicenseInfo, setCurrentLicenseInfo] = useState<LicenseInfo | null>(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 <StaticPlanSection currentLicenseInfo={currentLicenseInfo ?? undefined} />;
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
// Early returns after all hooks are called
@ -139,7 +117,7 @@ const AdminPlanSection: React.FC = () => {
if (error) {
// Fallback to static version on error
return <StaticPlanSection currentLicenseInfo={currentLicenseInfo ?? undefined} />;
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
if (!plans || plans.length === 0) {
@ -171,7 +149,7 @@ const AdminPlanSection: React.FC = () => {
</Group>
{/* Manage Subscription Button - Only show if user has active license */}
{currentLicenseInfo?.licenseKey && (
{licenseInfo?.licenseKey && (
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
@ -184,7 +162,7 @@ const AdminPlanSection: React.FC = () => {
<AvailablePlansSection
plans={plans}
currentLicenseInfo={currentLicenseInfo}
currentLicenseInfo={licenseInfo}
onUpgradeClick={handleUpgradeClick}
/>

View File

@ -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<CheckoutProviderProps> = ({
defaultCurrency = 'gbp'
}) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency);
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
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<CheckoutProviderProps> = ({
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<CheckoutProviderProps> = ({
} 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<CheckoutProviderProps> = ({
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<CheckoutProviderProps> = ({
};
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<CheckoutProviderProps> = ({
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<CheckoutProviderProps> = ({
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
onLicenseActivated={handleLicenseActivated}
hostedCheckoutSuccess={hostedCheckoutSuccess}
/>
)}
</CheckoutContext.Provider>

View File

@ -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<void>;
}
const LicenseContext = createContext<LicenseContextValue | undefined>(undefined);
interface LicenseProviderProps {
children: ReactNode;
}
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 (
<LicenseContext.Provider value={contextValue}>
{children}
</LicenseContext.Provider>
);
};
export const useLicense = (): LicenseContextValue => {
const context = useContext(LicenseContext);
if (!context) {
throw new Error('useLicense must be used within LicenseProvider');
}
return context;
};