From 8d9e70c796c03afa7e3e7ce388305f9455143fc5 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:07:37 +0000 Subject: [PATCH] Stripe and license payment integration (#4935) selfhosted stripe payment and license integration --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: Connor Yoh --- .../configuration/ee/LicenseKeyChecker.java | 7 +- .../api/AdminLicenseController.java | 245 +++++++++ .../api/AdminSettingsController.java | 10 + frontend/package-lock.json | 153 +++++- frontend/package.json | 3 + .../public/locales/en-GB/translation.json | 88 ++- frontend/src/core/services/supabaseClient.ts | 20 + .../proprietary/components/AppProviders.tsx | 10 +- .../components/shared/ManageBillingButton.tsx | 53 ++ .../components/shared/StripeCheckout.tsx | 507 ++++++++++++++++++ .../components/shared/UpgradeBanner.tsx | 144 +++++ .../shared/config/configNavSections.tsx | 9 + .../configSections/AdminPlanSection.tsx | 240 +++++++++ .../plan/AvailablePlansSection.tsx | 109 ++++ .../plan/FeatureComparisonTable.tsx | 93 ++++ .../config/configSections/plan/PlanCard.tsx | 202 +++++++ .../configSections/plan/StaticPlanSection.tsx | 338 ++++++++++++ .../proprietary/constants/planConstants.ts | 97 ++++ .../proprietary/contexts/CheckoutContext.tsx | 350 ++++++++++++ .../proprietary/contexts/LicenseContext.tsx | 74 +++ frontend/src/proprietary/hooks/usePlans.ts | 44 ++ .../proprietary/services/licenseService.ts | 475 ++++++++++++++++ .../proprietary/utils/licenseCheckoutUtils.ts | 265 +++++++++ .../proprietary/utils/protocolDetection.ts | 43 ++ frontend/vite-env.d.ts | 2 + 25 files changed, 3553 insertions(+), 28 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java create mode 100644 frontend/src/core/services/supabaseClient.ts create mode 100644 frontend/src/proprietary/components/shared/ManageBillingButton.tsx create mode 100644 frontend/src/proprietary/components/shared/StripeCheckout.tsx create mode 100644 frontend/src/proprietary/components/shared/UpgradeBanner.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx create mode 100644 frontend/src/proprietary/constants/planConstants.ts create mode 100644 frontend/src/proprietary/contexts/CheckoutContext.tsx create mode 100644 frontend/src/proprietary/contexts/LicenseContext.tsx create mode 100644 frontend/src/proprietary/hooks/usePlans.ts create mode 100644 frontend/src/proprietary/services/licenseService.ts create mode 100644 frontend/src/proprietary/utils/licenseCheckoutUtils.ts create mode 100644 frontend/src/proprietary/utils/protocolDetection.ts 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 212922d55..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 @@ -113,7 +113,12 @@ public class LicenseKeyChecker { public void updateLicenseKey(String newKey) throws IOException { applicationProperties.getPremium().setKey(newKey); - GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey); + GeneralUtils.saveKeyToSettings("premium.key", newKey); + evaluateLicense(); + synchronizeLicenseSettings(); + } + + public void resyncLicense() { evaluateLicense(); synchronizeLicenseSettings(); } 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 new file mode 100644 index 000000000..7bd5836c8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java @@ -0,0 +1,245 @@ +package stirling.software.proprietary.security.controller.api; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.GeneralUtils; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; +import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; + +/** + * Admin controller for license management. Provides installation ID for Stripe checkout metadata + * and endpoints for managing license keys. + */ +@RestController +@Slf4j +@RequestMapping("/api/v1/admin") +@PreAuthorize("hasRole('ROLE_ADMIN')") +@Tag(name = "Admin License Management", description = "Admin-only License Management APIs") +public class AdminLicenseController { + + @Autowired(required = false) + private LicenseKeyChecker licenseKeyChecker; + + @Autowired(required = false) + private KeygenLicenseVerifier keygenLicenseVerifier; + + @Autowired private ApplicationProperties applicationProperties; + + /** + * Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used + * as metadata in Stripe checkout to link licenses to specific installations. + * + * @return Map containing the installation ID + */ + @GetMapping("/installation-id") + @Operation( + summary = "Get installation ID", + description = + "Returns the unique installation ID (MAC-based fingerprint) for this" + + " self-hosted instance") + public ResponseEntity> getInstallationId() { + try { + String installationId = GeneralUtils.generateMachineFingerprint(); + log.info("Admin requested installation ID: {}", installationId); + return ResponseEntity.ok(Map.of("installationId", installationId)); + } catch (Exception e) { + log.error("Failed to generate installation ID", e); + return ResponseEntity.internalServerError() + .body(Map.of("error", "Failed to generate installation ID")); + } + } + + /** + * Save and activate a license key. This endpoint accepts a license key from the frontend (e.g., + * after Stripe checkout) and activates it on the backend. + * + * @param request Map containing the license key + * @return Response with success status, license type, and whether restart is required + */ + @PostMapping("/license-key") + @Operation( + summary = "Save and activate license key", + description = + "Accepts a license key and activates it on the backend. Returns the activated" + + " license type.") + public ResponseEntity> saveLicenseKey( + @RequestBody Map request) { + String licenseKey = request.get("licenseKey"); + + // Reject null but allow empty string to clear license + if (licenseKey == null) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "error", "License key is required")); + } + + try { + if (licenseKeyChecker == null) { + return ResponseEntity.internalServerError() + .body(Map.of("success", false, "error", "License checker not available")); + } + // assume premium enabled when setting license key + applicationProperties.getPremium().setEnabled(true); + + // Use existing LicenseKeyChecker to update and validate license + // Empty string will be evaluated as NORMAL license (free tier) + licenseKeyChecker.updateLicenseKey(licenseKey.trim()); + + // Get current license status + License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); + + // Auto-enable premium features if license is valid + 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); + } + } else { + GeneralUtils.saveKeyToSettings("premium.enabled", false); + log.info("License key is not valid for premium features: type={}", license.name()); + } + + Map response = new HashMap<>(); + response.put("success", true); + response.put("licenseType", license.name()); + response.put("enabled", applicationProperties.getPremium().isEnabled()); + response.put("maxUsers", applicationProperties.getPremium().getMaxUsers()); + response.put("requiresRestart", false); // Dynamic evaluation works + response.put("message", "License key saved and activated"); + + log.info("License key saved and activated: type={}", license.name()); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("Failed to save license key", e); + return ResponseEntity.badRequest() + .body( + Map.of( + "success", + false, + "error", + "Failed to activate license: " + e.getMessage())); + } + } + + /** + * 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. + * + * @return Map containing license information + */ + @GetMapping("/license-info") + @Operation( + summary = "Get license information", + description = + "Returns information about the current license including type, enabled status," + + " and max users") + public ResponseEntity> getLicenseInfo() { + try { + Map response = new HashMap<>(); + + if (licenseKeyChecker != null) { + License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); + response.put("licenseType", license.name()); + } else { + response.put("licenseType", License.NORMAL.name()); + } + + ApplicationProperties.Premium premium = applicationProperties.getPremium(); + response.put("enabled", premium.isEnabled()); + response.put("maxUsers", premium.getMaxUsers()); + response.put("hasKey", premium.getKey() != null && !premium.getKey().trim().isEmpty()); + + // Include license key for upgrades (admin-only endpoint) + if (premium.getKey() != null && !premium.getKey().trim().isEmpty()) { + response.put("licenseKey", premium.getKey()); + } + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("Failed to get license info", e); + return ResponseEntity.internalServerError() + .body(Map.of("error", "Failed to retrieve license information")); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 27c924ae4..a4bdd9da5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -299,6 +299,16 @@ public class AdminSettingsController { + String.join(", ", VALID_SECTION_NAMES)); } + // Auto-enable premium features if license key is provided + if ("premium".equalsIgnoreCase(sectionName) && sectionData.containsKey("key")) { + Object keyValue = sectionData.get("key"); + if (keyValue != null && !keyValue.toString().trim().isEmpty()) { + // Automatically set enabled to true when a key is provided + sectionData.put("enabled", true); + log.info("Auto-enabling premium features because license key was provided"); + } + } + int updatedCount = 0; for (Map.Entry entry : sectionData.entrySet()) { String propertyKey = entry.getKey(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6d4c6c529..86f3f9a36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,9 @@ "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", "@reactour/tour": "^3.8.0", + "@stripe/react-stripe-js": "^4.0.2", + "@stripe/stripe-js": "^7.9.0", + "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.5.0", @@ -3121,6 +3124,138 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz", + "integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=1.44.1 <8.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", + "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz", + "integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/auth-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/functions-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz", + "integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz", + "integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/realtime-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz", + "integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/storage-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz", + "integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/supabase-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz", + "integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.81.1", + "@supabase/functions-js": "2.81.1", + "@supabase/postgrest-js": "2.81.1", + "@supabase/realtime-js": "2.81.1", + "@supabase/storage-js": "2.81.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", @@ -4203,7 +4338,6 @@ "version": "24.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -4215,6 +4349,12 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4249,6 +4389,15 @@ "@types/react": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -13962,7 +14111,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -14833,7 +14981,6 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/frontend/package.json b/frontend/package.json index a0d831d0e..dbfd9d347 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,9 @@ "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", "@mantine/hooks": "^8.3.1", + "@stripe/react-stripe-js": "^4.0.2", + "@stripe/stripe-js": "^7.9.0", + "@supabase/supabase-js": "^2.47.13", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", "@reactour/tour": "^3.8.0", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c2943e6cb..2b70c36ca 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -2415,7 +2415,7 @@ }, "cta": "Compare", "loading": "Comparing...", - + "summary": { "baseHeading": "Original document", "comparisonHeading": "Edited document", @@ -2471,7 +2471,7 @@ "body": "This comparison is taking longer than usual. You can let it continue or cancel it.", "cancel": "Cancel comparison" }, - + "newLine": "new-line", "complex": { "message": "One or both of the provided documents are large files, accuracy of comparison may be reduced" @@ -4419,9 +4419,21 @@ "title": "Premium & Enterprise", "description": "Configure your premium or enterprise license key.", "license": "License Configuration", + "licenseKey": { + "toggle": "Got a license key or certificate file?", + "info": "If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features." + }, "key": { "label": "License Key", - "description": "Enter your premium or enterprise license key" + "description": "Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.", + "success": "License Key Saved", + "successMessage": "Your license key has been activated successfully. No restart required.", + "overwriteWarning": { + "title": "⚠️ Warning: Existing License Detected", + "line1": "Overwriting your current license key cannot be undone.", + "line2": "Your previous license will be permanently lost unless you have backed it up elsewhere.", + "line3": "Important: Keep license keys private and secure. Never share them publicly." + } }, "enabled": { "label": "Enable Premium Features", @@ -4850,7 +4862,11 @@ "available": "available", "cancel": "Cancel", "preview": "Preview", - "done": "Done" + "close": "Close", + "done": "Done", + "loading": "Loading...", + "back": "Back", + "continue": "Continue" }, "config": { "overview": { @@ -5268,6 +5284,14 @@ "showComparison": "Compare All Features", "hideComparison": "Hide Feature Comparison", "featureComparison": "Feature Comparison", + "from": "From", + "perMonth": "/month", + "licensedSeats": "Licensed: {{count}} seats", + "includedInCurrent": "Included in Your Plan", + "selectPlan": "Select Plan", + "manageSubscription": { + "description": "Manage your subscription, billing, and payment methods" + }, "activePlan": { "title": "Active Plan", "subtitle": "Your current subscription details" @@ -5285,13 +5309,16 @@ "upTo": "Up to" }, "period": { - "month": "month" + "month": "month", + "perUserPerMonth": "/user/month" }, "free": { "name": "Free", "highlight1": "Limited Tool Usage Per week", "highlight2": "Access to all tools", - "highlight3": "Community support" + "highlight3": "Community support", + "forever": "Forever free", + "included": "Included" }, "pro": { "name": "Pro", @@ -5333,13 +5360,44 @@ "error": "Failed to open billing portal" } }, + "upgradeBanner": { + "title": "Upgrade to Server Plan", + "message": "Get the most out of Stirling PDF with unlimited users and advanced features", + "upgradeButton": "Upgrade Now", + "dismiss": "Dismiss banner" + }, "payment": { "preparing": "Preparing your checkout...", "upgradeTitle": "Upgrade to {{planName}}", "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", @@ -5635,21 +5693,5 @@ "pleaseLoginAgain": "Please login again.", "accessDenied": "Access Denied", "insufficientPermissions": "You do not have permission to perform this action." - }, - "common": { - "loading": "Loading...", - "back": "Back", - "continue": "Continue", - "preview": "Preview", - "previous": "Previous", - "next": "Next", - "copied": "Copied!", - "copy": "Copy", - "expand": "Expand", - "collapse": "Collapse", - "retry": "Retry", - "refresh": "Refresh", - "cancel": "Cancel", - "done": "Done" } } diff --git a/frontend/src/core/services/supabaseClient.ts b/frontend/src/core/services/supabaseClient.ts new file mode 100644 index 000000000..c0ace3002 --- /dev/null +++ b/frontend/src/core/services/supabaseClient.ts @@ -0,0 +1,20 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; + +// Check if Supabase is configured +export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey); + +// Create client only if configured, otherwise export null +export const supabase: SupabaseClient | null = isSupabaseConfigured + ? createClient(supabaseUrl, supabaseAnonKey) + : null; + +// Log warning if not configured (for self-hosted installations) +if (!isSupabaseConfigured) { + console.warn( + 'Supabase is not configured. Checkout and billing features will be disabled. ' + + 'Static plan information will be displayed instead.' + ); +} diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx index f0cadfa51..b865e4f26 100644 --- a/frontend/src/proprietary/components/AppProviders.tsx +++ b/frontend/src/proprietary/components/AppProviders.tsx @@ -1,5 +1,8 @@ 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"; export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { return ( @@ -8,7 +11,12 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide appConfigProviderProps={appConfigProviderProps} > - {children} + + + + {children} + + ); diff --git a/frontend/src/proprietary/components/shared/ManageBillingButton.tsx b/frontend/src/proprietary/components/shared/ManageBillingButton.tsx new file mode 100644 index 000000000..fc523f62f --- /dev/null +++ b/frontend/src/proprietary/components/shared/ManageBillingButton.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { Button } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import licenseService from '@app/services/licenseService'; +import { alert } from '@app/components/toast'; + +interface ManageBillingButtonProps { + returnUrl?: string; +} + +export const ManageBillingButton: React.FC = ({ + returnUrl = window.location.href, +}) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const handleClick = async () => { + try { + setLoading(true); + + // Get current license key for authentication + const licenseInfo = await licenseService.getLicenseInfo(); + + if (!licenseInfo.licenseKey) { + throw new Error('No license key found. Please activate a license first.'); + } + + // Create billing portal session with license key + const response = await licenseService.createBillingPortalSession( + returnUrl, + licenseInfo.licenseKey + ); + + // Open billing portal in new tab + window.open(response.url, '_blank'); + setLoading(false); + } catch (error: any) { + console.error('Failed to open billing portal:', error); + alert({ + alertType: 'error', + title: t('billing.portal.error', 'Failed to open billing portal'), + body: error.message || 'Please try again or contact support.', + }); + setLoading(false); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/proprietary/components/shared/StripeCheckout.tsx b/frontend/src/proprietary/components/shared/StripeCheckout.tsx new file mode 100644 index 000000000..6018dcafc --- /dev/null +++ b/frontend/src/proprietary/components/shared/StripeCheckout.tsx @@ -0,0 +1,507 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Modal, Button, Text, Alert, Loader, Stack, Group, Paper, SegmentedControl, Grid, Code } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +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, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils'; + +// Validate Stripe key (static validation, no dynamic imports) +const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; + +if (!STRIPE_KEY) { + console.error( + 'VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. ' + + 'Please add it to your .env file. ' + + 'Get your key from https://dashboard.stripe.com/apikeys' + ); +} + +if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) { + console.error( + `Invalid Stripe publishable key format. ` + + `Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...` + ); +} + +const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null; + +interface StripeCheckoutProps { + opened: boolean; + onClose: () => void; + planGroup: PlanTierGroup; + minimumSeats?: number; + 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 = { + status: 'idle' | 'loading' | 'ready' | 'success' | 'error'; + clientSecret?: string; + error?: string; + sessionId?: string; +}; + +const StripeCheckout: React.FC = ({ + opened, + onClose, + planGroup, + minimumSeats = 1, + onSuccess, + onError, + onLicenseActivated, + hostedCheckoutSuccess, +}) => { + const { t } = useTranslation(); + const [state, setState] = useState({ status: 'idle' }); + // Default to yearly if available (better value), otherwise monthly + const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>( + planGroup.yearly ? 'yearly' : 'monthly' + ); + const [installationId, setInstallationId] = useState(null); + const [currentLicenseKey, setCurrentLicenseKey] = useState(null); + const [licenseKey, setLicenseKey] = useState(null); + const [pollingStatus, setPollingStatus] = useState<'idle' | 'polling' | 'ready' | 'timeout'>('idle'); + + // Refs for polling cleanup + const isMountedRef = React.useRef(true); + const pollingTimeoutRef = React.useRef(null); + + // Get the selected plan based on period + const selectedPlan = selectedPeriod === 'yearly' ? planGroup.yearly : planGroup.monthly; + + const createCheckoutSession = async () => { + if (!selectedPlan) { + setState({ + status: 'error', + error: 'Selected plan period is not available', + }); + return; + } + + try { + setState({ status: 'loading' }); + + // Fetch installation ID from backend + let fetchedInstallationId = installationId; + if (!fetchedInstallationId) { + fetchedInstallationId = await licenseService.getInstallationId(); + setInstallationId(fetchedInstallationId); + } + + // Fetch current license key for upgrades + let existingLicenseKey: string | undefined; + try { + const licenseInfo = await licenseService.getLicenseInfo(); + if (licenseInfo && licenseInfo.licenseKey) { + existingLicenseKey = licenseInfo.licenseKey; + setCurrentLicenseKey(existingLicenseKey); + console.log('Found existing license for upgrade'); + } + } catch (error) { + console.warn('Could not fetch license info, proceeding as new license:', error); + } + + const response = await licenseService.createCheckoutSession({ + lookup_key: selectedPlan.lookupKey, + installation_id: fetchedInstallationId, + current_license_key: existingLicenseKey, + requires_seats: selectedPlan.requiresSeats, + seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)), + }); + + // Check if we got a redirect URL (hosted checkout for HTTP) + if (response.url) { + console.log('Redirecting to Stripe hosted checkout:', response.url); + // Redirect to Stripe's hosted checkout page + window.location.href = response.url; + return; + } + + // Otherwise, use embedded checkout (HTTPS) + setState({ + status: 'ready', + clientSecret: response.clientSecret, + sessionId: response.sessionId, + }); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to create checkout session'; + setState({ + status: 'error', + error: errorMessage, + }); + onError?.(errorMessage); + } + }; + + const pollForLicenseKey = useCallback(async (installId: string) => { + // Use shared polling utility + const result = await pollLicenseKeyWithBackoff(installId, { + isMounted: () => isMountedRef.current, + onStatusChange: setPollingStatus, + }); + + if (result.success && result.licenseKey) { + setLicenseKey(result.licenseKey); + + // Activate the license key + const activation = await activateLicenseKey(result.licenseKey, { + isMounted: () => isMountedRef.current, + onActivated: onLicenseActivated, + }); + + if (!activation.success) { + console.error('Failed to activate license key:', activation.error); + } + } else if (result.timedOut) { + console.warn('License key polling timed out'); + } else if (result.error) { + console.error('License key polling failed:', result.error); + } + }, [onLicenseActivated]); + + const handlePaymentComplete = async () => { + // Preserve state when changing status + setState(prev => ({ ...prev, status: 'success' })); + + // Check if this is an upgrade (existing license key) or new plan + if (currentLicenseKey) { + // UPGRADE FLOW: Resync existing license with Keygen + console.log('Upgrade detected - resyncing existing license with Keygen'); + setPollingStatus('polling'); + + const activation = await resyncExistingLicense({ + isMounted: () => true, // Modal is open, no need to check + onActivated: onLicenseActivated, + }); + + if (activation.success) { + console.log(`License upgraded successfully: ${activation.licenseType}`); + setPollingStatus('ready'); + } else { + console.error('Failed to sync upgraded license:', activation.error); + setPollingStatus('timeout'); + } + + // Notify parent (don't wait - upgrade is complete) + onSuccess?.(state.sessionId || ''); + } else { + // NEW PLAN FLOW: Poll for new license key + console.log('New subscription - polling for license key'); + + if (installationId) { + pollForLicenseKey(installationId).finally(() => { + // Only notify parent after polling completes or times out + onSuccess?.(state.sessionId || ''); + }); + } else { + // No installation ID, notify immediately + onSuccess?.(state.sessionId || ''); + } + } + }; + + const handleClose = () => { + // Clear any active polling + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + pollingTimeoutRef.current = null; + } + + setState({ status: 'idle' }); + setPollingStatus('idle'); + setCurrentLicenseKey(null); + setLicenseKey(null); + // Reset to default period on close + setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly'); + onClose(); + }; + + const handlePeriodChange = (value: string) => { + setSelectedPeriod(value as 'monthly' | 'yearly'); + // Reset state to trigger checkout reload + setState({ status: 'idle' }); + }; + + // Cleanup on unmount + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + pollingTimeoutRef.current = null; + } + }; + }, []); + + // 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) + if (state.status === 'success') { + 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, hostedCheckoutSuccess]); + + const renderContent = () => { + // Check if Stripe is configured + if (!stripePromise) { + return ( + + + + {t( + 'payment.stripeNotConfiguredMessage', + 'Stripe payment integration is not configured. Please contact your administrator.' + )} + + + + + ); + } + + switch (state.status) { + case 'loading': + return ( + + + + {t('payment.preparing', 'Preparing your checkout...')} + + + ); + + case 'ready': + { + if (!state.clientSecret || !selectedPlan) return null; + + // Build period selector data with prices + const periodData = []; + if (planGroup.monthly) { + const monthlyPrice = planGroup.monthly.requiresSeats && planGroup.monthly.seatPrice + ? `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period} + ${planGroup.monthly.currency}${planGroup.monthly.seatPrice.toFixed(2)}/seat` + : `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period}`; + + periodData.push({ + value: 'monthly', + label: `${t('payment.monthly', 'Monthly')} - ${monthlyPrice}`, + }); + } + if (planGroup.yearly) { + const yearlyPrice = planGroup.yearly.requiresSeats && planGroup.yearly.seatPrice + ? `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period} + ${planGroup.yearly.currency}${planGroup.yearly.seatPrice.toFixed(2)}/seat` + : `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period}`; + + periodData.push({ + value: 'yearly', + label: `${t('payment.yearly', 'Yearly')} - ${yearlyPrice}`, + }); + } + + return ( + + {/* Left: Period Selector - only show if both periods available */} + {periodData.length > 1 && ( + + + + {t('payment.billingPeriod', 'Billing Period')} + + + {selectedPlan.requiresSeats && selectedPlan.seatPrice && ( + + {t('payment.enterpriseNote', 'Seats can be adjusted in checkout (1-1000).')} + + )} + + + )} + + {/* Right: Stripe Checkout */} + 1 ? 9 : 12}> + + + + + + ); + } + case 'success': + return ( + + + + {t( + 'payment.successMessage', + 'Your subscription has been activated successfully.' + )} + + + {/* License Key Polling Status */} + {pollingStatus === 'polling' && ( + + + + {currentLicenseKey + ? t('payment.syncingLicense', 'Syncing your upgraded license...') + : t('payment.generatingLicense', 'Generating your license key...')} + + + )} + + {pollingStatus === 'ready' && !currentLicenseKey && licenseKey && ( + + + + {t('payment.licenseKey', 'Your License Key')} + + {licenseKey} + + + {t( + 'payment.licenseInstructions', + 'Enter this key in Settings → Admin Plan → License Key section' + )} + + + + )} + + {pollingStatus === 'ready' && currentLicenseKey && ( + + + {t( + 'payment.upgradeCompleteMessage', + 'Your subscription has been upgraded successfully. Your existing license key has been updated.' + )} + + + )} + + {pollingStatus === 'timeout' && ( + + + {t( + 'payment.licenseDelayedMessage', + 'Your license key is being generated. Please check your email shortly or contact support.' + )} + + + )} + + {pollingStatus === 'ready' && ( + + {t('payment.canCloseWindow', 'You can now close this window.')} + + )} + + + ); + + case 'error': + return ( + + + {state.error} + + + + ); + + default: + return null; + } + }; + + return ( + + {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: planGroup.name })} + + } + size="90%" + centered + withCloseButton={true} + closeOnEscape={true} + closeOnClickOutside={false} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + styles={{ + body: { + minHeight: '85vh', + }, + content: { + maxHeight: '95vh', + }, + }} + > + {renderContent()} + + ); +}; + +export default StripeCheckout; diff --git a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx new file mode 100644 index 000000000..93a5eb827 --- /dev/null +++ b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx @@ -0,0 +1,144 @@ +import React, { useState, useEffect } from 'react'; +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 { useLicense } from '@app/contexts/LicenseContext'; +import { mapLicenseToTier } from '@app/services/licenseService'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { isSupabaseConfigured } from '@app/services/supabaseClient'; + +/** + * UpgradeBanner - Dismissable top banner encouraging users to upgrade + * + * This component demonstrates: + * - How to check authentication status with useAuth() + * - How to check license status with licenseService + * - How to open checkout modal with useCheckout() + * - How to persist dismissal state with localStorage + * + * To remove this banner: + * 1. Remove the import and component from AppProviders.tsx + * 2. Delete this file + */ +const UpgradeBanner: React.FC = () => { + const { t } = useTranslation(); + const { user } = useAuth(); + const { openCheckout } = useCheckout(); + const { licenseInfo, loading: licenseLoading } = useLicense(); + const [isVisible, setIsVisible] = useState(false); + + // Check if user should see the banner + useEffect(() => { + // Don't show if not logged in + if (!user) { + setIsVisible(false); + return; + } + + // Don't show if Supabase is not configured (no checkout available) + if (!isSupabaseConfigured) { + setIsVisible(false); + return; + } + + // Don't show while license is loading + if (licenseLoading) { + return; + } + + // Check if banner was dismissed + const dismissed = localStorage.getItem('upgradeBannerDismissed'); + if (dismissed === 'true') { + setIsVisible(false); + return; + } + + // Check license status from global context + const tier = mapLicenseToTier(licenseInfo); + + // 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 = () => { + localStorage.setItem('upgradeBannerDismissed', 'true'); + setIsVisible(false); + }; + + // Handle upgrade button click + const handleUpgrade = () => { + openCheckout('server', { + currency: 'gbp', + minimumSeats: 1, + onSuccess: () => { + // Banner will auto-hide on next render when license is detected + setIsVisible(false); + }, + }); + }; + + // Don't render anything if loading or not visible + if (licenseLoading || !isVisible) { + return null; + } + + return ( + + + + +
+ + {t('upgradeBanner.title', 'Upgrade to Server Plan')} + + + {t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')} + +
+
+ + + + + + + +
+
+ ); +}; + +export default UpgradeBanner; diff --git a/frontend/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/src/proprietary/components/shared/config/configNavSections.tsx index bc63e979a..2b27a4ba9 100644 --- a/frontend/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/src/proprietary/components/shared/config/configNavSections.tsx @@ -10,6 +10,7 @@ import AdminDatabaseSection from '@app/components/shared/config/configSections/A import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection'; import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection'; import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection'; +import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection'; import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection'; import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection'; import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection'; @@ -136,6 +137,14 @@ export const createConfigNavSections = ( disabled: requiresLogin, disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined }, + { + key: 'adminPlan', + label: 'Plan', + icon: 'star-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, { key: 'adminAudit', label: 'Audit', diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx new file mode 100644 index 000000000..522658004 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -0,0 +1,240 @@ +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 } 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 { alert } from '@app/components/toast'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { ManageBillingButton } from '@app/components/shared/ManageBillingButton'; +import { isSupabaseConfigured } from '@app/services/supabaseClient'; + +const AdminPlanSection: React.FC = () => { + const { t } = useTranslation(); + const { openCheckout } = useCheckout(); + const { licenseInfo, refetchLicense } = useLicense(); + const [currency, setCurrency] = useState('gbp'); + const [useStaticVersion, setUseStaticVersion] = useState(false); + const [showLicenseKey, setShowLicenseKey] = useState(false); + const [licenseKeyInput, setLicenseKeyInput] = useState(''); + const [savingLicense, setSavingLicense] = useState(false); + const { plans, loading, error, refetch } = usePlans(currency); + + // Check if we should use static version + useEffect(() => { + // Check if Stripe and Supabase are configured + const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; + if (!stripeKey || !isSupabaseConfigured || error) { + setUseStaticVersion(true); + } + }, [error]); + + const handleSaveLicense = async () => { + try { + setSavingLicense(true); + // Allow empty string to clear/remove license + const response = await licenseService.saveLicenseKey(licenseKeyInput.trim()); + + if (response.success) { + // Refresh license context to update all components + await refetchLicense(); + + alert({ + alertType: 'success', + title: t('admin.settings.premium.key.success', 'License Key Saved'), + body: t('admin.settings.premium.key.successMessage', 'Your license key has been activated successfully. No restart required.'), + }); + + // Clear input + setLicenseKeyInput(''); + } else { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: response.error || t('admin.settings.saveError', 'Failed to save license key'), + }); + } + } catch (error) { + console.error('Failed to save license key:', error); + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.saveError', 'Failed to save license key'), + }); + } finally { + setSavingLicense(false); + } + }; + + const currencyOptions = [ + { value: 'gbp', label: 'British pound (GBP, £)' }, + { value: 'usd', label: 'US dollar (USD, $)' }, + { value: 'eur', label: 'Euro (EUR, €)' }, + { value: 'cny', label: 'Chinese yuan (CNY, ¥)' }, + { value: 'inr', label: 'Indian rupee (INR, ₹)' }, + { value: 'brl', label: 'Brazilian real (BRL, R$)' }, + { value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' }, + ]; + + const handleUpgradeClick = useCallback( + (planGroup: PlanTierGroup) => { + // Only allow upgrades for server and enterprise tiers + if (planGroup.tier === 'free') { + return; + } + + // Use checkout context to open checkout modal + openCheckout(planGroup.tier, { + currency, + onSuccess: () => { + // Refetch plans after successful payment + // License context will auto-update + refetch(); + }, + }); + }, + [openCheckout, currency, refetch] + ); + + // Show static version if Stripe is not configured or there's an error + if (useStaticVersion) { + return ; + } + + // Early returns after all hooks are called + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + // Fallback to static version on error + return ; + } + + if (!plans || plans.length === 0) { + return ( + + Plans data is not available at the moment. + + ); + } + + return ( +
+ {/* Currency Selection & Manage Subscription */} + + + + + {t('plan.currency', 'Currency')} + +