From afa71c002bf41629874279708a79abe5439e8d2b Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 19 Nov 2025 12:10:48 +0000 Subject: [PATCH] public resync license http license translations --- .../configuration/ee/LicenseKeyChecker.java | 5 + .../api/AdminLicenseController.java | 60 +++++++++ .../public/locales/en-GB/translation.json | 27 +++- .../components/shared/StripeCheckout.tsx | 8 +- .../configSections/AdminPlanSection.tsx | 105 ---------------- .../proprietary/contexts/CheckoutContext.tsx | 117 +++++++++++++++++- .../proprietary/services/licenseService.ts | 14 +++ .../proprietary/utils/licenseCheckoutUtils.ts | 68 +++++++++- .../proprietary/utils/protocolDetection.ts | 2 +- 9 files changed, 293 insertions(+), 113 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java index 68e4ab707..050a565c2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java @@ -118,6 +118,11 @@ public class LicenseKeyChecker { synchronizeLicenseSettings(); } + public void resyncLicense() { + evaluateLicense(); + synchronizeLicenseSettings(); + } + public License getPremiumLicenseEnabledResult() { return premiumEnabledResult; } 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 0f83ba602..2354da7fc 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 @@ -143,6 +143,66 @@ public class AdminLicenseController { } } + /** + * Resync the current license with Keygen. This endpoint re-validates the existing license key + * and updates the max users setting. Used after subscription upgrades to sync the new license + * limits. + * + * @return Response with updated license information + */ + @PostMapping("/license/resync") + @Operation( + summary = "Resync license with Keygen", + description = + "Re-validates the existing license key with Keygen and updates local settings." + + " Used after subscription upgrades.") + public ResponseEntity> resyncLicense() { + try { + if (licenseKeyChecker == null) { + return ResponseEntity.internalServerError() + .body(Map.of("success", false, "error", "License checker not available")); + } + + String currentKey = applicationProperties.getPremium().getKey(); + if (currentKey == null || currentKey.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "error", "No license key configured")); + } + + log.info("Resyncing license with Keygen"); + + // Re-validate license and sync settings + licenseKeyChecker.resyncLicense(); + + // Get updated license status + License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); + ApplicationProperties.Premium premium = applicationProperties.getPremium(); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("licenseType", license.name()); + response.put("enabled", premium.isEnabled()); + response.put("maxUsers", premium.getMaxUsers()); + response.put("message", "License resynced successfully"); + + log.info( + "License resynced: type={}, maxUsers={}", + license.name(), + premium.getMaxUsers()); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("Failed to resync license", e); + return ResponseEntity.internalServerError() + .body( + Map.of( + "success", + false, + "error", + "Failed to resync license: " + e.getMessage())); + } + } + /** * Get information about the current license key status, including license type, enabled status, * and max users. diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 9909f4c5d..f48110dcc 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5156,7 +5156,32 @@ "success": "Payment Successful!", "successMessage": "Your subscription has been activated successfully. You will receive a confirmation email shortly.", "autoClose": "This window will close automatically...", - "error": "Payment Error" + "error": "Payment Error", + "upgradeSuccess": "Payment successful! Your subscription has been upgraded. The license has been updated on your server. You will receive a confirmation email shortly.", + "paymentSuccess": "Payment successful! Retrieving your license key...", + "licenseActivated": "License activated! Your license key has been saved. A confirmation email has been sent to your registered email address.", + "licenseDelayed": "Payment successful! Your license is being generated. You will receive an email with your license key shortly. If you don't receive it within 10 minutes, please contact support.", + "licensePollingError": "Payment successful but we couldn't retrieve your license key automatically. Please check your email or contact support with your payment confirmation.", + "licenseRetrievalError": "Payment successful but license retrieval failed. You will receive your license key via email. Please contact support if you don't receive it within 10 minutes.", + "syncError": "Payment successful but license sync failed. Your license will be updated shortly. Please contact support if issues persist.", + "licenseSaveError": "Failed to save license key. Please contact support with your license key to complete activation.", + "paymentCanceled": "Payment was canceled. No charges were made.", + "syncingLicense": "Syncing your upgraded license...", + "generatingLicense": "Generating your license key...", + "upgradeComplete": "Upgrade Complete", + "upgradeCompleteMessage": "Your subscription has been upgraded successfully. Your existing license key has been updated.", + "stripeNotConfigured": "Stripe Not Configured", + "stripeNotConfiguredMessage": "Stripe payment integration is not configured. Please contact your administrator.", + "monthly": "Monthly", + "yearly": "Yearly", + "billingPeriod": "Billing Period", + "enterpriseNote": "Seats can be adjusted in checkout (1-1000).", + "installationId": "Installation ID", + "licenseKey": "Your License Key", + "licenseInstructions": "Enter this key in Settings → Admin Plan → License Key section", + "canCloseWindow": "You can now close this window.", + "licenseKeyProcessing": "License Key Processing", + "licenseDelayedMessage": "Your license key is being generated. Please check your email shortly or contact support." }, "firstLogin": { "title": "First Time Login", diff --git a/frontend/src/proprietary/components/shared/StripeCheckout.tsx b/frontend/src/proprietary/components/shared/StripeCheckout.tsx index b421417aa..87729d9ec 100644 --- a/frontend/src/proprietary/components/shared/StripeCheckout.tsx +++ b/frontend/src/proprietary/components/shared/StripeCheckout.tsx @@ -5,7 +5,7 @@ import { loadStripe } from '@stripe/stripe-js'; import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js'; import licenseService, { PlanTierGroup } from '@app/services/licenseService'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; -import { pollLicenseKeyWithBackoff, activateLicenseKey } from '@app/utils/licenseCheckoutUtils'; +import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils'; // Validate Stripe key (static validation, no dynamic imports) const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; @@ -168,11 +168,11 @@ const StripeCheckout: React.FC = ({ // Check if this is an upgrade (existing license key) or new plan if (currentLicenseKey) { - // UPGRADE FLOW: Force license re-verification by saving existing key - console.log('Upgrade detected - syncing existing license key'); + // UPGRADE FLOW: Resync existing license with Keygen + console.log('Upgrade detected - resyncing existing license with Keygen'); setPollingStatus('polling'); - const activation = await activateLicenseKey(currentLicenseKey, { + const activation = await resyncExistingLicense({ isMounted: () => true, // Modal is open, no need to check onActivated: onLicenseActivated, }); diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index 31ca06fac..86d4dac54 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -15,7 +15,6 @@ import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { ManageBillingButton } from '@app/components/shared/ManageBillingButton'; -import { pollLicenseKeyWithBackoff, activateLicenseKey } from '@app/utils/licenseCheckoutUtils'; interface PremiumSettingsData { key?: string; @@ -62,107 +61,6 @@ const AdminPlanSection: React.FC = () => { } }; - // Handle return from hosted Stripe checkout - const handleCheckoutReturn = async () => { - const urlParams = new URLSearchParams(window.location.search); - const paymentStatus = urlParams.get('payment_status'); - const sessionId = urlParams.get('session_id'); - - if (paymentStatus === 'success' && sessionId) { - console.log('Payment successful via hosted checkout:', sessionId); - - // Clear URL parameters - window.history.replaceState({}, '', window.location.pathname); - - // Check if this is an upgrade or new subscription - if (currentLicenseInfo?.licenseKey) { - // UPGRADE: Sync existing license key - console.log('Upgrade detected - syncing existing license'); - - const activation = await activateLicenseKey(currentLicenseInfo.licenseKey, { - onActivated: fetchLicenseInfo, - }); - - if (activation.success) { - alert({ - message: t('payment.upgradeSuccess', 'Your subscription has been upgraded successfully!'), - color: 'green', - }); - } else { - console.error('Failed to sync license after upgrade:', activation.error); - alert({ - message: t('payment.syncError', 'Payment successful but license sync failed. Please contact support.'), - color: 'red', - }); - } - } else { - // NEW SUBSCRIPTION: Poll for license key - console.log('New subscription - polling for license key'); - alert({ - message: t('payment.paymentSuccess', 'Payment successful! Retrieving your license key...'), - color: 'green', - }); - - try { - const installationId = await licenseService.getInstallationId(); - console.log('Polling for license key with installation ID:', installationId); - - // Use shared polling utility - const result = await pollLicenseKeyWithBackoff(installationId); - - if (result.success && result.licenseKey) { - // Activate the license key - const activation = await activateLicenseKey(result.licenseKey, { - onActivated: fetchLicenseInfo, - }); - - if (activation.success) { - console.log(`License key activated: ${activation.licenseType}`); - alert({ - message: t('payment.licenseActivated', 'License key activated successfully!'), - color: 'green', - }); - } else { - console.error('Failed to save license key:', activation.error); - alert({ - message: t('payment.licenseSaveError', 'Failed to save license key. Please contact support.'), - color: 'red', - }); - } - } else if (result.timedOut) { - console.warn('License key polling timed out'); - alert({ - message: t('payment.licenseDelayed', 'License key is being generated. Please check back shortly or contact support.'), - color: 'yellow', - }); - } else { - console.error('License key polling failed:', result.error); - alert({ - message: t('payment.licensePollingError', 'Failed to retrieve license key. Please check your email or contact support.'), - color: 'red', - }); - } - } catch (error) { - console.error('Failed to poll for license key:', error); - alert({ - message: t('payment.licenseRetrievalError', 'Failed to retrieve license key. Please check your email or contact support.'), - color: 'red', - }); - } - } - } else if (paymentStatus === 'canceled') { - console.log('Payment canceled by user'); - - // Clear URL parameters - window.history.replaceState({}, '', window.location.pathname); - - alert({ - message: t('payment.paymentCanceled', 'Payment was canceled.'), - color: 'yellow', - }); - } - }; - // Check if Stripe is configured const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; if (!stripeKey || error) { @@ -170,9 +68,6 @@ const AdminPlanSection: React.FC = () => { } fetchLicenseInfo(); - // Handle checkout return after license info is loaded - handleCheckoutReturn(); - // Fetch premium settings fetchPremiumSettings(); }, [error, config]); diff --git a/frontend/src/proprietary/contexts/CheckoutContext.tsx b/frontend/src/proprietary/contexts/CheckoutContext.tsx index 21604eef8..47666a645 100644 --- a/frontend/src/proprietary/contexts/CheckoutContext.tsx +++ b/frontend/src/proprietary/contexts/CheckoutContext.tsx @@ -1,8 +1,11 @@ -import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { usePlans } from '@app/hooks/usePlans'; import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService'; 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'; export interface CheckoutOptions { minimumSeats?: number; // Override calculated seats for enterprise @@ -32,6 +35,7 @@ export const CheckoutProvider: React.FC = ({ children, defaultCurrency = 'gbp' }) => { + const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectedPlanGroup, setSelectedPlanGroup] = useState(null); @@ -42,6 +46,117 @@ export const CheckoutProvider: React.FC = ({ // Load plans with current currency const { plans, refetch: refetchPlans } = usePlans(currentCurrency); + // Handle return from hosted Stripe checkout + useEffect(() => { + const handleCheckoutReturn = async () => { + const urlParams = new URLSearchParams(window.location.search); + const paymentStatus = urlParams.get('payment_status'); + const sessionId = urlParams.get('session_id'); + + if (paymentStatus === 'success' && sessionId) { + console.log('Payment successful via hosted checkout:', sessionId); + + // Clear URL parameters + window.history.replaceState({}, '', window.location.pathname); + + // Fetch current license info to determine upgrade vs new + let licenseInfo: LicenseInfo | null = null; + try { + licenseInfo = await licenseService.getLicenseInfo(); + } catch (err) { + console.warn('Could not fetch license info:', err); + } + + // Check if this is an upgrade or new subscription + if (licenseInfo?.licenseKey) { + // UPGRADE: Resync existing license with Keygen + console.log('Upgrade detected - resyncing existing license'); + + const activation = await resyncExistingLicense(); + + if (activation.success) { + alert({ + alertType: 'success', + title: t('payment.upgradeSuccess'), + }); + refetchPlans(); // Refresh plans to show updated subscription + } else { + console.error('Failed to sync license after upgrade:', activation.error); + alert({ + alertType: 'error', + title: t('payment.syncError'), + }); + } + } 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(); + console.log('Polling for license key with installation ID:', installationId); + + // Use shared polling utility + const result = await pollLicenseKeyWithBackoff(installationId); + + if (result.success && result.licenseKey) { + // Activate the license key + const activation = await activateLicenseKey(result.licenseKey); + + if (activation.success) { + console.log(`License key activated: ${activation.licenseType}`); + alert({ + alertType: 'success', + title: t('payment.licenseActivated'), + }); + refetchPlans(); // Refresh plans to show updated subscription + } else { + console.error('Failed to save license key:', activation.error); + alert({ + alertType: 'error', + title: t('payment.licenseSaveError'), + }); + } + } else if (result.timedOut) { + console.warn('License key polling timed out'); + alert({ + alertType: 'warning', + title: t('payment.licenseDelayed'), + }); + } else { + console.error('License key polling failed:', result.error); + alert({ + alertType: 'error', + title: t('payment.licensePollingError'), + }); + } + } catch (error) { + console.error('Failed to poll for license key:', error); + alert({ + alertType: 'error', + title: t('payment.licenseRetrievalError'), + }); + } + } + } else if (paymentStatus === 'canceled') { + console.log('Payment canceled by user'); + + // Clear URL parameters + window.history.replaceState({}, '', window.location.pathname); + + alert({ + alertType: 'warning', + title: t('payment.paymentCanceled'), + }); + } + }; + + handleCheckoutReturn(); + }, [t, refetchPlans]); + const openCheckout = useCallback( async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => { try { diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts index 5e4920092..8724e74e9 100644 --- a/frontend/src/proprietary/services/licenseService.ts +++ b/frontend/src/proprietary/services/licenseService.ts @@ -470,6 +470,20 @@ const licenseService = { throw error; } }, + + /** + * Resync the current license with Keygen + * Re-validates the existing license key and updates local settings + */ + async resyncLicense(): Promise { + try { + const response = await apiClient.post('/api/v1/admin/license/resync'); + return response.data; + } catch (error) { + console.error('Error resyncing license:', error); + throw error; + } + }, }; /** diff --git a/frontend/src/proprietary/utils/licenseCheckoutUtils.ts b/frontend/src/proprietary/utils/licenseCheckoutUtils.ts index f3090de2e..6235bbfe9 100644 --- a/frontend/src/proprietary/utils/licenseCheckoutUtils.ts +++ b/frontend/src/proprietary/utils/licenseCheckoutUtils.ts @@ -134,7 +134,7 @@ export interface LicenseActivationResult { /** * Activate a license key by saving it to the backend and fetching updated info - * Consolidates activation logic used by both embedded and hosted checkout + * Used for NEW subscriptions where we have a new license key to save */ export async function activateLicenseKey( licenseKey: string, @@ -197,3 +197,69 @@ export async function activateLicenseKey( }; } } + +/** + * Resync existing license with Keygen + * Used for UPGRADES where we already have a license key configured + * Calls the dedicated resync endpoint instead of re-saving the same key + */ +export async function resyncExistingLicense( + options: { + /** Check if component is still mounted */ + isMounted?: () => boolean; + /** Callback when license is resynced with updated info */ + onActivated?: (licenseInfo: LicenseInfo) => void; + } = {} +): Promise { + const { isMounted = () => true, onActivated } = options; + + try { + console.log('Resyncing existing license with Keygen...'); + const resyncResponse = await licenseService.resyncLicense(); + + if (!isMounted()) { + return { success: false, error: 'Component unmounted' }; + } + + if (resyncResponse.success) { + console.log(`License resynced: ${resyncResponse.licenseType}`); + + // Fetch updated license info + try { + const licenseInfo = await licenseService.getLicenseInfo(); + + if (!isMounted()) { + return { success: false, error: 'Component unmounted' }; + } + + onActivated?.(licenseInfo); + + return { + success: true, + licenseType: resyncResponse.licenseType, + licenseInfo, + }; + } catch (infoError) { + console.error('Error fetching license info after resync:', infoError); + // Still return success since resync succeeded + return { + success: true, + licenseType: resyncResponse.licenseType, + error: 'Failed to fetch updated license info', + }; + } + } else { + console.error('Failed to resync license:', resyncResponse.error); + return { + success: false, + error: resyncResponse.error || 'Failed to resync license', + }; + } + } catch (error) { + console.error('Error resyncing license:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Resync failed', + }; + } +} diff --git a/frontend/src/proprietary/utils/protocolDetection.ts b/frontend/src/proprietary/utils/protocolDetection.ts index 1f7ff52d9..9bd9ce03b 100644 --- a/frontend/src/proprietary/utils/protocolDetection.ts +++ b/frontend/src/proprietary/utils/protocolDetection.ts @@ -10,7 +10,7 @@ export function isSecureContext(): boolean { // Allow localhost for development (works with both HTTP and HTTPS) if (typeof window !== 'undefined') { - const hostname = window.location.hostname; + // const hostname = window.location.hostname; const protocol = window.location.protocol; // Localhost is considered secure for development