diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java new file mode 100644 index 000000000..08362fa0b --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java @@ -0,0 +1,49 @@ +package stirling.software.SPDF.config; + +import java.io.IOException; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.service.WeeklyActiveUsersService; + +/** + * Filter to track browser IDs for Weekly Active Users (WAU) counting. + * Only active when security is disabled (no-login mode). + */ +@Component +@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false") +@RequiredArgsConstructor +@Slf4j +public class WAUTrackingFilter implements Filter { + + private final WeeklyActiveUsersService wauService; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + if (request instanceof HttpServletRequest httpRequest) { + // Extract browser ID from header + String browserId = httpRequest.getHeader("X-Browser-Id"); + + if (browserId != null && !browserId.trim().isEmpty()) { + // Record browser access + wauService.recordBrowserAccess(browserId); + } + } + + // Continue the filter chain + chain.doFilter(request, response); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 0823c29e9..fc578fdbc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer { "tauri://localhost", "http://tauri.localhost", "https://tauri.localhost") - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") - .allowedHeaders("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN", + "X-XSRF-TOKEN", + "X-Browser-Id") + .exposedHeaders( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type") .allowCredentials(true) .maxAge(3600); } else if (hasConfiguredOrigins) { @@ -63,13 +79,53 @@ public class WebMvcConfig implements WebMvcConfigurer { .toArray(new String[0]); registry.addMapping("/**") - .allowedOrigins(allowedOrigins) - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") - .allowedHeaders("*") + .allowedOriginPatterns(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN", + "X-XSRF-TOKEN", + "X-Browser-Id") + .exposedHeaders( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type") + .allowCredentials(true) + .maxAge(3600); + } else { + // Default to allowing all origins when nothing is configured + logger.info( + "No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins."); + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN", + "X-XSRF-TOKEN", + "X-Browser-Id") + .exposedHeaders( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type") .allowCredentials(true) .maxAge(3600); } - // If no origins are configured and not in Tauri mode, CORS is not enabled (secure by - // default) } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index ffbec5a7d..a4a169cd9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -154,6 +154,25 @@ public class ConfigController { // EE features not available, continue without them } + // Add version and machine info for update checking + try { + if (applicationContext.containsBean("appVersion")) { + configData.put( + "appVersion", applicationContext.getBean("appVersion", String.class)); + } + if (applicationContext.containsBean("machineType")) { + configData.put( + "machineType", applicationContext.getBean("machineType", String.class)); + } + if (applicationContext.containsBean("activeSecurity")) { + configData.put( + "activeSecurity", + applicationContext.getBean("activeSecurity", Boolean.class)); + } + } catch (Exception e) { + // Version/machine info not available + } + return ResponseEntity.ok(configData); } catch (Exception e) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index 8f17e0baf..53e60a6b5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointInspector; import stirling.software.SPDF.config.StartupApplicationListener; +import stirling.software.SPDF.service.WeeklyActiveUsersService; import stirling.software.common.annotations.api.InfoApi; import stirling.software.common.model.ApplicationProperties; @@ -34,6 +35,7 @@ public class MetricsController { private final ApplicationProperties applicationProperties; private final MeterRegistry meterRegistry; private final EndpointInspector endpointInspector; + private final Optional wauService; private boolean metricsEnabled; @PostConstruct @@ -352,6 +354,35 @@ public class MetricsController { return ResponseEntity.ok(formatDuration(uptime)); } + @GetMapping("/wau") + @Operation( + summary = "Weekly Active Users statistics", + description = + "Returns WAU (Weekly Active Users) count and total unique browsers. " + + "Only available when security is disabled (no-login mode). " + + "Tracks unique browsers via client-generated UUID in localStorage.") + public ResponseEntity getWeeklyActiveUsers() { + if (!metricsEnabled) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled."); + } + + // Check if WAU service is available (only when security.enableLogin=false) + if (wauService.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("WAU tracking is only available when security is disabled (no-login mode)"); + } + + WeeklyActiveUsersService service = wauService.get(); + + Map wauStats = new HashMap<>(); + wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers()); + wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers()); + wauStats.put("daysOnline", service.getDaysOnline()); + wauStats.put("trackingSince", service.getStartTime().toString()); + + return ResponseEntity.ok(wauStats); + } + private String formatDuration(Duration duration) { long days = duration.toDays(); long hours = duration.toHoursPart(); diff --git a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java new file mode 100644 index 000000000..ddf3a7b26 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java @@ -0,0 +1,100 @@ +package stirling.software.SPDF.service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * Service for tracking Weekly Active Users (WAU) in no-login mode. + * Uses in-memory storage with automatic cleanup of old entries. + */ +@Service +@Slf4j +public class WeeklyActiveUsersService { + + // Map of browser ID -> last seen timestamp + private final Map activeBrowsers = new ConcurrentHashMap<>(); + + // Track total unique browsers seen (overall) + private long totalUniqueBrowsers = 0; + + // Application start time + private final Instant startTime = Instant.now(); + + /** + * Records a browser access with the current timestamp + * @param browserId Unique browser identifier from X-Browser-Id header + */ + public void recordBrowserAccess(String browserId) { + if (browserId == null || browserId.trim().isEmpty()) { + return; + } + + boolean isNewBrowser = !activeBrowsers.containsKey(browserId); + activeBrowsers.put(browserId, Instant.now()); + + if (isNewBrowser) { + totalUniqueBrowsers++; + log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers); + } + } + + /** + * Gets the count of unique browsers seen in the last 7 days + * @return Weekly Active Users count + */ + public long getWeeklyActiveUsers() { + cleanupOldEntries(); + return activeBrowsers.size(); + } + + /** + * Gets the total count of unique browsers ever seen + * @return Total unique browsers count + */ + public long getTotalUniqueBrowsers() { + return totalUniqueBrowsers; + } + + /** + * Gets the number of days the service has been running + * @return Days online + */ + public long getDaysOnline() { + return ChronoUnit.DAYS.between(startTime, Instant.now()); + } + + /** + * Gets the timestamp when tracking started + * @return Start time + */ + public Instant getStartTime() { + return startTime; + } + + /** + * Removes entries older than 7 days + */ + private void cleanupOldEntries() { + Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS); + activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo)); + } + + /** + * Manual cleanup trigger (can be called by scheduled task if needed) + */ + public void performCleanup() { + int sizeBefore = activeBrowsers.size(); + cleanupOldEntries(); + int sizeAfter = activeBrowsers.size(); + + if (sizeBefore != sizeAfter) { + log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter); + } + } +} 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 f43d8bc69..862a03e29 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", @@ -3127,6 +3130,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", @@ -4212,6 +4347,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", @@ -4246,6 +4387,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", @@ -13963,7 +14113,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 +14982,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 3fa614f35..3e2a528d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,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 c9cd9c692..a64417ac6 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -362,7 +362,15 @@ "defaultPdfEditorInactive": "Another application is set as default", "defaultPdfEditorChecking": "Checking...", "defaultPdfEditorSet": "Already Default", - "setAsDefault": "Set as Default" + "setAsDefault": "Set as Default", + "updates": { + "title": "Software Updates", + "description": "Check for updates and view version information", + "currentVersion": "Current Version", + "latestVersion": "Latest Version", + "checkForUpdates": "Check for Updates", + "viewDetails": "View Details" + } }, "hotkeys": { "title": "Keyboard Shortcuts", @@ -383,6 +391,37 @@ "searchPlaceholder": "Search tools..." } }, + "update": { + "modalTitle": "Update Available", + "current": "Current Version", + "latest": "Latest Version", + "latestStable": "Latest Stable", + "priorityLabel": "Priority", + "recommendedAction": "Recommended Action", + "breakingChangesDetected": "Breaking Changes Detected", + "breakingChangesMessage": "Some versions contain breaking changes. Please review the migration guides below before updating.", + "migrationGuides": "Migration Guides", + "viewGuide": "View Guide", + "loadingDetailedInfo": "Loading detailed information...", + "close": "Close", + "viewAllReleases": "View All Releases", + "downloadLatest": "Download Latest", + "availableUpdates": "Available Updates", + "unableToLoadDetails": "Unable to load detailed information.", + "version": "Version", + "urgentUpdateAvailable": "Urgent Update", + "updateAvailable": "Update Available", + "releaseNotes": "Release Notes", + "priority": { + "urgent": "Urgent", + "normal": "Normal", + "minor": "Minor", + "low": "Low" + }, + "breakingChanges": "Breaking Changes", + "breakingChangesDefault": "This version contains breaking changes.", + "migrationGuide": "Migration Guide" + }, "changeCreds": { "title": "Change Credentials", "header": "Update Your Account Details", @@ -2095,9 +2134,23 @@ "title": "Draw your signature", "clear": "Clear" }, + "canvas": { + "heading": "Draw your signature", + "clickToOpen": "Click to open the drawing canvas", + "modalTitle": "Draw your signature", + "colorLabel": "Colour", + "penSizeLabel": "Pen size", + "penSizePlaceholder": "Size", + "clear": "Clear canvas", + "colorPickerTitle": "Choose stroke colour" + }, "text": { "name": "Signer Name", - "placeholder": "Enter your full name" + "placeholder": "Enter your full name", + "fontLabel": "Font", + "fontSizeLabel": "Font size", + "fontSizePlaceholder": "Type or select font size (8-200)", + "colorLabel": "Text colour" }, "clear": "Clear", "add": "Add", @@ -2120,6 +2173,11 @@ "steps": { "configure": "Configure Signature" }, + "step": { + "createDesc": "Choose how you want to create the signature", + "place": "Place & save", + "placeDesc": "Position the signature on your PDF" + }, "type": { "title": "Signature Type", "draw": "Draw", @@ -2136,11 +2194,16 @@ "title": "How to add signature", "canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.", "image": "After uploading your signature image above, click anywhere on the PDF to place it.", - "text": "After entering your name above, click anywhere on the PDF to place your signature." + "text": "After entering your name above, click anywhere on the PDF to place your signature.", + "paused": "Placement paused", + "resumeHint": "Resume placement to click and add your signature.", + "noSignature": "Create a signature above to enable placement tools." }, "mode": { "move": "Move Signature", - "place": "Place Signature" + "place": "Place Signature", + "pause": "Pause placement", + "resume": "Resume placement" }, "updateAndPlace": "Update and Place", "activate": "Activate Signature Placement", @@ -2323,7 +2386,7 @@ }, "cta": "Compare", "loading": "Comparing...", - + "summary": { "baseHeading": "Original document", "comparisonHeading": "Edited document", @@ -2379,7 +2442,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" @@ -4327,9 +4390,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", @@ -4739,6 +4814,9 @@ "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } }, + "colorPicker": { + "title": "Choose colour" + }, "common": { "previous": "Previous", "next": "Next", @@ -4754,7 +4832,9 @@ "used": "used", "available": "available", "cancel": "Cancel", - "preview": "Preview" + "preview": "Preview", + "close": "Close", + "done": "Done" }, "config": { "overview": { @@ -5172,6 +5252,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" @@ -5189,13 +5277,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", @@ -5237,13 +5328,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", diff --git a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx index c61b61cfd..ea093b5be 100644 --- a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx +++ b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Stack, Alert, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { DrawingControls } from '@app/components/annotation/shared/DrawingControls'; import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider'; +import { useSignature } from '@app/contexts/SignatureContext'; export interface AnnotationToolConfig { enableDrawing?: boolean; @@ -32,10 +33,34 @@ export const BaseAnnotationTool: React.FC = ({ undo, redo } = usePDFAnnotation(); + const { historyApiRef } = useSignature(); const [selectedColor, setSelectedColor] = useState('#000000'); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [signatureData, setSignatureData] = useState(null); + const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false }); + const historyApiInstance = historyApiRef.current; + + useEffect(() => { + if (!historyApiInstance) { + setHistoryAvailability({ canUndo: false, canRedo: false }); + return; + } + + const updateAvailability = () => { + setHistoryAvailability({ + canUndo: historyApiInstance.canUndo?.() ?? false, + canRedo: historyApiInstance.canRedo?.() ?? false, + }); + }; + + const unsubscribe = historyApiInstance.subscribe?.(updateAvailability); + updateAvailability(); + + return () => { + unsubscribe?.(); + }; + }, [historyApiInstance]); const handleSignatureDataChange = (data: string | null) => { setSignatureData(data); @@ -54,6 +79,8 @@ export const BaseAnnotationTool: React.FC = ({ = ({ /> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/src/core/components/annotation/shared/ColorPicker.tsx index 40bb363b4..04ae501bb 100644 --- a/frontend/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/src/core/components/annotation/shared/ColorPicker.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; interface ColorPickerProps { isOpen: boolean; @@ -14,13 +15,16 @@ export const ColorPicker: React.FC = ({ onClose, selectedColor, onColorChange, - title = "Choose Color" + title }) => { + const { t } = useTranslation(); + const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour'); + return ( @@ -36,7 +40,7 @@ export const ColorPicker: React.FC = ({ /> @@ -64,4 +68,4 @@ export const ColorSwatchButton: React.FC = ({ onClick={onClick} /> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index e8600e0a2..52908edbc 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -1,5 +1,6 @@ -import React, { useRef, useState } from 'react'; -import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core'; +import React, { useEffect, useRef, useState } from 'react'; +import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker'; import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector'; import SignaturePad from 'signature_pad'; @@ -20,6 +21,7 @@ interface DrawingCanvasProps { modalWidth?: number; modalHeight?: number; additionalButtons?: React.ReactNode; + initialSignatureData?: string; } export const DrawingCanvas: React.FC = ({ @@ -34,12 +36,14 @@ export const DrawingCanvas: React.FC = ({ disabled = false, width = 400, height = 150, + initialSignatureData, }) => { + const { t } = useTranslation(); const previewCanvasRef = useRef(null); const modalCanvasRef = useRef(null); const padRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); - const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [savedSignatureData, setSavedSignatureData] = useState(null); const initPad = (canvas: HTMLCanvasElement) => { if (!padRef.current) { @@ -55,6 +59,18 @@ export const DrawingCanvas: React.FC = ({ minDistance: 5, velocityFilterWeight: 0.7, }); + + // Restore saved signature data if it exists + if (savedSignatureData) { + const img = new Image(); + img.onload = () => { + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + } + }; + img.src = savedSignatureData; + } } }; @@ -104,36 +120,35 @@ export const DrawingCanvas: React.FC = ({ return trimmedCanvas.toDataURL('image/png'); }; + const renderPreview = (dataUrl: string) => { + const canvas = previewCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const img = new Image(); + img.onload = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + const scale = Math.min(canvas.width / img.width, canvas.height / img.height); + const scaledWidth = img.width * scale; + const scaledHeight = img.height * scale; + const x = (canvas.width - scaledWidth) / 2; + const y = (canvas.height - scaledHeight) / 2; + + ctx.drawImage(img, x, y, scaledWidth, scaledHeight); + }; + img.src = dataUrl; + }; + const closeModal = () => { if (padRef.current && !padRef.current.isEmpty()) { const canvas = modalCanvasRef.current; if (canvas) { const trimmedPng = trimCanvas(canvas); + const untrimmedPng = canvas.toDataURL('image/png'); + setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration onSignatureDataChange(trimmedPng); - - // Update preview canvas with proper aspect ratio - const img = new Image(); - img.onload = () => { - if (previewCanvasRef.current) { - const ctx = previewCanvasRef.current.getContext('2d'); - if (ctx) { - ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); - - // Calculate scaling to fit within preview canvas while maintaining aspect ratio - const scale = Math.min( - previewCanvasRef.current.width / img.width, - previewCanvasRef.current.height / img.height - ); - const scaledWidth = img.width * scale; - const scaledHeight = img.height * scale; - const x = (previewCanvasRef.current.width - scaledWidth) / 2; - const y = (previewCanvasRef.current.height - scaledHeight) / 2; - - ctx.drawImage(img, x, y, scaledWidth, scaledHeight); - } - } - }; - img.src = trimmedPng; + renderPreview(trimmedPng); if (onDrawingComplete) { onDrawingComplete(); @@ -157,6 +172,7 @@ export const DrawingCanvas: React.FC = ({ ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); } } + setSavedSignatureData(null); // Clear saved signature onSignatureDataChange(null); }; @@ -173,67 +189,70 @@ export const DrawingCanvas: React.FC = ({ } }; + useEffect(() => { + updatePenColor(selectedColor); + }, [selectedColor]); + + useEffect(() => { + updatePenSize(penSize); + }, [penSize]); + + useEffect(() => { + const canvas = previewCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + if (!initialSignatureData) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + return; + } + + renderPreview(initialSignatureData); + }, [initialSignatureData]); + return ( <> - Draw your signature - + {t('sign.canvas.heading', 'Draw your signature')} + - Click to open drawing canvas + {t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')} - + -
-
- Color - - -
- setColorPickerOpen(!colorPickerOpen)} - /> -
-
- - { - onColorSwatchClick(); - updatePenColor(color); - }} - swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']} - /> - -
-
-
- Pen Size + + + + {t('sign.canvas.colorLabel', 'Colour')} + + + + + + {t('sign.canvas.penSizeLabel', 'Pen size')} + = ({ updatePenSize(size); }} onInputChange={onPenSizeInputChange} - placeholder="Size" + placeholder={t('sign.canvas.penSizePlaceholder', 'Size')} size="compact-sm" - style={{ width: '60px' }} + style={{ width: '80px' }} /> -
-
+
+ = ({ backgroundColor: 'white', width: '100%', maxWidth: '800px', - height: '400px', + height: '25rem', cursor: 'crosshair', }} /> @@ -271,10 +290,10 @@ export const DrawingCanvas: React.FC = ({
diff --git a/frontend/src/core/components/annotation/shared/DrawingControls.tsx b/frontend/src/core/components/annotation/shared/DrawingControls.tsx index 62c7c615f..3c28a594e 100644 --- a/frontend/src/core/components/annotation/shared/DrawingControls.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingControls.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Group, Button } from '@mantine/core'; +import { Group, Button, ActionIcon, Tooltip } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { LocalIcon } from '@app/components/shared/LocalIcon'; interface DrawingControlsProps { onUndo?: () => void; @@ -8,8 +9,11 @@ interface DrawingControlsProps { onPlaceSignature?: () => void; hasSignatureData?: boolean; disabled?: boolean; + canUndo?: boolean; + canRedo?: boolean; showPlaceButton?: boolean; placeButtonText?: string; + additionalControls?: React.ReactNode; } export const DrawingControls: React.FC = ({ @@ -18,30 +22,48 @@ export const DrawingControls: React.FC = ({ onPlaceSignature, hasSignatureData = false, disabled = false, + canUndo = true, + canRedo = true, showPlaceButton = true, - placeButtonText = "Update and Place" + placeButtonText = "Update and Place", + additionalControls, }) => { const { t } = useTranslation(); + const undoDisabled = disabled || !canUndo; + const redoDisabled = disabled || !canRedo; return ( - - {/* Undo/Redo Controls */} - - + + {onUndo && ( + + + + + + )} + {onRedo && ( + + + + + + )} + + {additionalControls} {/* Place Signature Button */} {showPlaceButton && onPlaceSignature && ( @@ -50,11 +72,11 @@ export const DrawingControls: React.FC = ({ color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} - flex={1} + ml="auto" > {placeButtonText} )} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index aca7430ce..c700d4d05 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -34,12 +34,18 @@ export const TextInputWithFont: React.FC = ({ const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); const fontSizeCombobox = useCombobox(); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + const [colorInput, setColorInput] = useState(textColor); // Sync font size input with prop changes useEffect(() => { setFontSizeInput(fontSize.toString()); }, [fontSize]); + // Sync color input with prop changes + useEffect(() => { + setColorInput(textColor); + }, [textColor]); + const fontOptions = [ { value: 'Helvetica', label: 'Helvetica' }, { value: 'Times-Roman', label: 'Times' }, @@ -50,10 +56,15 @@ export const TextInputWithFont: React.FC = ({ const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200']; + // Validate hex color + const isValidHexColor = (color: string): boolean => { + return /^#[0-9A-Fa-f]{6}$/.test(color); + }; + return ( onTextChange(e.target.value)} @@ -63,7 +74,7 @@ export const TextInputWithFont: React.FC = ({ {/* Font Selection */} setCurrency(value || 'gbp')} + data={currencyOptions} + searchable + clearable={false} + w={300} + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + + + {/* Manage Subscription Button - Only show if user has active license */} + {licenseInfo?.licenseKey && ( + + + {t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')} + + + + )} + + + + + + + + {/* License Key Section */} +
+ + + + + } + > + + {t('admin.settings.premium.licenseKey.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.')} + + + + {/* Severe warning if license already exists */} + {licenseInfo?.licenseKey && ( + } + title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')} + > + + + {t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')} + + + {t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')} + + + {t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')} + + + + )} + + + + setLicenseKeyInput(e.target.value)} + placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} + type="password" + disabled={savingLicense} + /> + + + + + + + + +
+ + ); +}; + +export default AdminPlanSection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx new file mode 100644 index 000000000..f51e659a0 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx @@ -0,0 +1,176 @@ +import React, { useState, useMemo } from 'react'; +import { Button, Card, Badge, Text, Collapse } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService'; +import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard'; + +interface AvailablePlansSectionProps { + plans: PlanTier[]; + currentPlanId?: string; + currentLicenseInfo?: LicenseInfo | null; + onUpgradeClick: (planGroup: PlanTierGroup) => void; +} + +const AvailablePlansSection: React.FC = ({ + plans, + currentLicenseInfo, + onUpgradeClick, +}) => { + const { t } = useTranslation(); + const [showComparison, setShowComparison] = useState(false); + + // Group plans by tier (Free, Server, Enterprise) + const groupedPlans = useMemo(() => { + return licenseService.groupPlansByTier(plans); + }, [plans]); + + // Calculate current tier from license info + const currentTier = useMemo(() => { + return mapLicenseToTier(currentLicenseInfo || null); + }, [currentLicenseInfo]); + + // Determine if the current tier matches (checks both Stripe subscription and license) + const isCurrentTier = (tierGroup: PlanTierGroup): boolean => { + // Check license tier match + if (currentTier && tierGroup.tier === currentTier) { + return true; + } + return false; + }; + + // Determine if selecting this plan would be a downgrade + const isDowngrade = (tierGroup: PlanTierGroup): boolean => { + if (!currentTier) return false; + + // Define tier hierarchy: enterprise > server > free + const tierHierarchy: Record = { + 'enterprise': 3, + 'server': 2, + 'free': 1 + }; + + const currentLevel = tierHierarchy[currentTier] || 0; + const targetLevel = tierHierarchy[tierGroup.tier] || 0; + + return currentLevel > targetLevel; + }; + + return ( +
+

+ {t('plan.availablePlans.title', 'Available Plans')} +

+

+ {t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')} +

+ +
+ {groupedPlans.map((group) => ( + + ))} +
+ +
+ +
+ + + + + {t('plan.featureComparison', 'Feature Comparison')} + + +
+ + + + + {groupedPlans.map((group) => ( + + ))} + + + + {groupedPlans[0]?.features.map((_, featureIndex) => ( + + + {groupedPlans.map((group) => ( + + ))} + + ))} + +
+ {t('plan.feature.title', 'Feature')} + + {group.name} + {group.popular && ( + + {t('plan.popular', 'Popular')} + + )} +
+ {groupedPlans[0].features[featureIndex].name} + + {group.features[featureIndex]?.included ? ( + + ✓ + + ) : ( + + − + + )} +
+
+
+
+
+ ); +}; + +export default AvailablePlansSection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx new file mode 100644 index 000000000..fdf11033e --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { Button, Card, Badge, Text, Group, Stack, Divider } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { PlanTierGroup, LicenseInfo } from '@app/services/licenseService'; + +interface PlanCardProps { + planGroup: PlanTierGroup; + isCurrentTier: boolean; + isDowngrade: boolean; + currentLicenseInfo?: LicenseInfo | null; + onUpgradeClick: (planGroup: PlanTierGroup) => void; +} + +const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => { + const { t } = useTranslation(); + + // Render Free plan + if (planGroup.tier === 'free') { + return ( + + {isCurrentTier && ( + + {t('plan.current', 'Current Plan')} + + )} + +
+ + {planGroup.name} + + + £0 + + + {t('plan.free.forever', 'Forever free')} + +
+ + + {planGroup.highlights.map((highlight, index) => ( + + • {highlight} + + ))} + + +
+ + + + + ); + } + + // Render Server or Enterprise plans + const { monthly, yearly } = planGroup; + const isEnterprise = planGroup.tier === 'enterprise'; + + // Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent + let displayPrice = monthly?.price || 0; + let displaySeatPrice = monthly?.seatPrice; + let displayCurrency = monthly?.currency || '£'; + + if (yearly) { + displayPrice = Math.round(yearly.price / 12); + displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined; + displayCurrency = yearly.currency; + } + + return ( + + {isCurrentTier ? ( + + {t('plan.current', 'Current Plan')} + + ) : planGroup.popular ? ( + + {t('plan.popular', 'Popular')} + + ) : null} + + + {/* Tier Name */} +
+ + {planGroup.name} + +
+ + {/* "From" Pricing */} +
+ + {t('plan.from', 'From')} + + + {isEnterprise && displaySeatPrice !== undefined ? ( +
+ + + {displayCurrency}{displayPrice} + + + {t('plan.perMonth', '/month')} + + + + + {displayCurrency}{displaySeatPrice}/seat/month + +
+ ) : ( + + + {displayCurrency}{displayPrice} + + + {t('plan.perMonth', '/month')} + + + )} + + {/* Show seat count for enterprise plans when current */} + {isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && ( + + {t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })} + + )} +
+ + + + {/* Highlights */} + + {planGroup.highlights.map((highlight, index) => ( + + • {highlight} + + ))} + + +
+ + {/* Single Upgrade Button */} + + + + ); +}; + +export default PlanCard; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx new file mode 100644 index 000000000..bef21168f --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx @@ -0,0 +1,339 @@ +import React, { useState, useEffect } from 'react'; +import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal'; +import { useRestartServer } from '@app/components/shared/config/useRestartServer'; +import { useAdminSettings } from '@app/hooks/useAdminSettings'; +import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { alert } from '@app/components/toast'; +import { LicenseInfo } from '@app/services/licenseService'; + +interface PremiumSettingsData { + key?: string; + enabled?: boolean; +} + +interface StaticPlanSectionProps { + currentLicenseInfo?: LicenseInfo; +} + +const StaticPlanSection: React.FC = ({ currentLicenseInfo }) => { + const { t } = useTranslation(); + const [showLicenseKey, setShowLicenseKey] = useState(false); + + // Premium/License key management + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const { + settings: premiumSettings, + setSettings: setPremiumSettings, + loading: premiumLoading, + saving: premiumSaving, + fetchSettings: fetchPremiumSettings, + saveSettings: savePremiumSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'premium', + }); + + useEffect(() => { + fetchPremiumSettings(); + }, []); + + const handleSaveLicense = async () => { + try { + await savePremiumSettings(); + showRestartModal(); + } catch (_error) { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.saveError', 'Failed to save settings'), + }); + } + }; + + const staticPlans = [ + { + id: 'free', + name: t('plan.free.name', 'Free'), + price: 0, + currency: '£', + period: t('plan.period.month', '/month'), + highlights: [ + t('plan.free.highlight1', 'Limited Tool Usage Per week'), + t('plan.free.highlight2', 'Access to all tools'), + t('plan.free.highlight3', 'Community support'), + ], + features: [ + { name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true }, + { name: t('plan.feature.fileSize', 'File Size Limit'), included: false }, + { name: t('plan.feature.automation', 'Automate tool workflows'), included: false }, + { name: t('plan.feature.api', 'API Access'), included: false }, + { name: t('plan.feature.priority', 'Priority Support'), included: false }, + ], + maxUsers: 5, + }, + { + id: 'pro', + name: t('plan.pro.name', 'Pro'), + price: 8, + currency: '£', + period: t('plan.period.perUserPerMonth', '/user/month'), + popular: true, + highlights: [ + t('plan.pro.highlight1', 'Unlimited Tool Usage per user'), + t('plan.pro.highlight2', 'Advanced PDF tools'), + t('plan.pro.highlight3', 'No watermarks'), + ], + features: [ + { name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true }, + { name: t('plan.feature.fileSize', 'File Size Limit'), included: true }, + { name: t('plan.feature.automation', 'Automate tool workflows'), included: true }, + { name: t('plan.feature.api', 'Weekly API Credits'), included: true }, + { name: t('plan.feature.priority', 'Priority Support'), included: false }, + ], + maxUsers: 'Unlimited users', + }, + { + id: 'enterprise', + name: t('plan.enterprise.name', 'Enterprise'), + price: 0, + currency: '', + period: '', + highlights: [ + t('plan.enterprise.highlight1', 'Custom pricing'), + t('plan.enterprise.highlight2', 'Dedicated support'), + t('plan.enterprise.highlight3', 'Latest features'), + ], + features: [ + { name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true }, + { name: t('plan.feature.fileSize', 'File Size Limit'), included: true }, + { name: t('plan.feature.automation', 'Automate tool workflows'), included: true }, + { name: t('plan.feature.api', 'Weekly API Credits'), included: true }, + { name: t('plan.feature.priority', 'Priority Support'), included: true }, + ], + maxUsers: 'Custom', + }, + ]; + + const getCurrentPlan = () => { + if (!currentLicenseInfo) return staticPlans[0]; + if (currentLicenseInfo.licenseType === 'ENTERPRISE') return staticPlans[2]; + if (currentLicenseInfo.maxUsers > 5) return staticPlans[1]; + return staticPlans[0]; + }; + + const currentPlan = getCurrentPlan(); + + return ( +
+ {/* Current Plan Section */} +
+

+ {t('plan.activePlan.title', 'Active Plan')} +

+

+ {t('plan.activePlan.subtitle', 'Your current subscription details')} +

+ + + + + + + {currentPlan.name} + + + {t('subscription.status.active', 'Active')} + + + {currentLicenseInfo && ( + + {t('plan.static.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers} + + )} + +
+ + {currentPlan.price === 0 ? t('plan.free.name', 'Free') : `${currentPlan.currency}${currentPlan.price}${currentPlan.period}`} + +
+
+
+
+ + {/* Available Plans */} +
+

+ {t('plan.availablePlans.title', 'Available Plans')} +

+

+ {t('plan.static.contactToUpgrade', 'Contact us to upgrade or customize your plan')} +

+ +
+ {staticPlans.map((plan) => ( + + {plan.popular && ( + + {t('plan.popular', 'Popular')} + + )} + + +
+ + {plan.name} + + + + {plan.price === 0 && plan.id !== 'free' + ? t('plan.customPricing', 'Custom') + : plan.price === 0 + ? t('plan.free.name', 'Free') + : `${plan.currency}${plan.price}`} + + {plan.period && ( + + {plan.period} + + )} + + + {typeof plan.maxUsers === 'string' + ? plan.maxUsers + : `${t('plan.static.upTo', 'Up to')} ${plan.maxUsers} ${t('workspace.people.license.users', 'users')}`} + +
+ + + {plan.highlights.map((highlight, index) => ( + + • {highlight} + + ))} + + +
+ + + + + ))} +
+
+ + + + {/* License Key Section */} +
+ + + + + } + > + + {t('admin.settings.premium.licenseKey.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.')} + + + + {premiumLoading ? ( + + + + ) : ( + + +
+ + {t('admin.settings.premium.key.label', 'License Key')} + + + } + description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')} + value={premiumSettings.key || ''} + onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })} + placeholder="00000000-0000-0000-0000-000000000000" + /> +
+ + + + +
+
+ )} +
+
+
+ + {/* Restart Confirmation Modal */} + +
+ ); +}; + +export default StaticPlanSection; diff --git a/frontend/src/proprietary/contexts/CheckoutContext.tsx b/frontend/src/proprietary/contexts/CheckoutContext.tsx new file mode 100644 index 000000000..2ec1f35d7 --- /dev/null +++ b/frontend/src/proprietary/contexts/CheckoutContext.tsx @@ -0,0 +1,344 @@ +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'; +import { useLicense } from '@app/contexts/LicenseContext'; + +export interface CheckoutOptions { + minimumSeats?: number; // Override calculated seats for enterprise + currency?: string; // Optional currency override (defaults to 'gbp') + onSuccess?: (sessionId: string) => void; // Callback after successful payment + onError?: (error: string) => void; // Callback on error +} + +interface CheckoutContextValue { + openCheckout: ( + tier: 'server' | 'enterprise', + options?: CheckoutOptions + ) => Promise; + closeCheckout: () => void; + isOpen: boolean; + isLoading: boolean; +} + +const CheckoutContext = createContext(undefined); + +interface CheckoutProviderProps { + children: ReactNode; + defaultCurrency?: string; +} + +export const CheckoutProvider: React.FC = ({ + children, + defaultCurrency = 'gbp' +}) => { + const { t } = useTranslation(); + const { refetchLicense } = useLicense(); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectedPlanGroup, setSelectedPlanGroup] = useState(null); + const [minimumSeats, setMinimumSeats] = useState(1); + const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency); + const [currentOptions, setCurrentOptions] = useState({}); + const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{ + isUpgrade: boolean; + licenseKey?: string; + } | null>(null); + + // Load plans with current currency + const { plans, refetch: refetchPlans } = usePlans(currentCurrency); + + // 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) { + 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({ + alertType: 'error', + title: t('payment.syncError'), + }); + } + } else { + // NEW SUBSCRIPTION: Poll for license key + console.log('New subscription - polling for license key'); + + 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}`); + + // 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({ + 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, refetchLicense, plans]); + + const openCheckout = useCallback( + async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => { + try { + setIsLoading(true); + + // Update currency if provided + const currency = options.currency || currentCurrency; + if (currency !== currentCurrency) { + setCurrentCurrency(currency); + // Plans will reload automatically via usePlans + } + + // Fetch license info and user data for seat calculations + let licenseInfo: LicenseInfo | null = null; + let totalUsers = 0; + + try { + const [licenseData, userData] = await Promise.all([ + licenseService.getLicenseInfo(), + userManagementService.getUsers() + ]); + + licenseInfo = licenseData; + totalUsers = userData.totalUsers || 0; + } catch (err) { + console.warn('Could not fetch license/user info, proceeding with defaults:', err); + } + + // Calculate minimum seats for enterprise upgrades + let calculatedMinSeats = options.minimumSeats || 1; + + if (tier === 'enterprise' && !options.minimumSeats) { + const currentTier = mapLicenseToTier(licenseInfo); + + if (currentTier === 'server' || currentTier === 'free') { + // Upgrading from Server (unlimited) to Enterprise (per-seat) + // Use current total user count as minimum + calculatedMinSeats = Math.max(totalUsers, 1); + console.log(`Setting minimum seats from server user count: ${calculatedMinSeats}`); + } else if (currentTier === 'enterprise') { + // Upgrading within Enterprise (e.g., monthly to yearly) + // Use current licensed seat count as minimum + calculatedMinSeats = Math.max(licenseInfo?.maxUsers || 1, 1); + console.log(`Setting minimum seats from current license: ${calculatedMinSeats}`); + } + } + + // Find the plan group for the requested tier + const planGroups = licenseService.groupPlansByTier(plans); + const planGroup = planGroups.find(pg => pg.tier === tier); + + if (!planGroup) { + throw new Error(`No ${tier} plan available`); + } + + // Store options for callbacks + setCurrentOptions(options); + setMinimumSeats(calculatedMinSeats); + setSelectedPlanGroup(planGroup); + setIsOpen(true); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to open checkout'; + console.error('Error opening checkout:', errorMessage); + options.onError?.(errorMessage); + } finally { + setIsLoading(false); + } + }, + [currentCurrency, plans] + ); + + const closeCheckout = useCallback(() => { + setIsOpen(false); + setSelectedPlanGroup(null); + setCurrentOptions({}); + setHostedCheckoutSuccess(null); + + // Refetch plans and license after modal closes to update subscription display + refetchPlans(); + refetchLicense(); + }, [refetchPlans, refetchLicense]); + + const handlePaymentSuccess = useCallback( + (sessionId: string) => { + console.log('Payment successful, session:', sessionId); + currentOptions.onSuccess?.(sessionId); + // Don't close modal - let user view license key and close manually + }, + [currentOptions] + ); + + const handlePaymentError = useCallback( + (error: string) => { + console.error('Payment error:', error); + currentOptions.onError?.(error); + }, + [currentOptions] + ); + + const handleLicenseActivated = useCallback((licenseInfo: { + licenseType: string; + enabled: boolean; + maxUsers: number; + hasKey: boolean; + }) => { + console.log('License activated:', licenseInfo); + // Could expose this via context if needed + }, []); + + const contextValue: CheckoutContextValue = { + openCheckout, + closeCheckout, + isOpen, + isLoading, + }; + + return ( + + {children} + + {/* Global Checkout Modal */} + {selectedPlanGroup && ( + + )} + + ); +}; + +export const useCheckout = (): CheckoutContextValue => { + const context = useContext(CheckoutContext); + if (!context) { + throw new Error('useCheckout must be used within CheckoutProvider'); + } + return context; +}; diff --git a/frontend/src/proprietary/contexts/LicenseContext.tsx b/frontend/src/proprietary/contexts/LicenseContext.tsx new file mode 100644 index 000000000..9f40428d6 --- /dev/null +++ b/frontend/src/proprietary/contexts/LicenseContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; +import licenseService, { LicenseInfo } from '@app/services/licenseService'; + +interface LicenseContextValue { + licenseInfo: LicenseInfo | null; + loading: boolean; + error: string | null; + refetchLicense: () => Promise; +} + +const LicenseContext = createContext(undefined); + +interface LicenseProviderProps { + children: ReactNode; +} + +export const LicenseProvider: React.FC = ({ children }) => { + const [licenseInfo, setLicenseInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetchLicense = useCallback(async () => { + try { + setLoading(true); + setError(null); + const info = await licenseService.getLicenseInfo(); + setLicenseInfo(info); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch license info'; + console.error('Error fetching license info:', errorMessage); + setError(errorMessage); + setLicenseInfo(null); + } finally { + setLoading(false); + } + }, []); + + // Fetch license info on mount + useEffect(() => { + refetchLicense(); + }, [refetchLicense]); + + const contextValue: LicenseContextValue = { + licenseInfo, + loading, + error, + refetchLicense, + }; + + return ( + + {children} + + ); +}; + +export const useLicense = (): LicenseContextValue => { + const context = useContext(LicenseContext); + if (!context) { + throw new Error('useLicense must be used within LicenseProvider'); + } + return context; +}; diff --git a/frontend/src/proprietary/hooks/usePlans.ts b/frontend/src/proprietary/hooks/usePlans.ts new file mode 100644 index 000000000..33cb198de --- /dev/null +++ b/frontend/src/proprietary/hooks/usePlans.ts @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react'; +import licenseService, { + PlanTier, + PlansResponse, +} from '@app/services/licenseService'; + +export interface UsePlansReturn { + plans: PlanTier[]; + loading: boolean; + error: string | null; + refetch: () => Promise; +} + +export const usePlans = (currency: string = 'gbp'): UsePlansReturn => { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPlans = async () => { + try { + setLoading(true); + setError(null); + + const data: PlansResponse = await licenseService.getPlans(currency); + setPlans(data.plans); + } catch (err) { + console.error('Error fetching plans:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch plans'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchPlans(); + }, [currency]); + + return { + plans, + loading, + error, + refetch: fetchPlans, + }; +}; diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts new file mode 100644 index 000000000..8724e74e9 --- /dev/null +++ b/frontend/src/proprietary/services/licenseService.ts @@ -0,0 +1,516 @@ +import apiClient from '@app/services/apiClient'; +import { supabase } from '@app/services/supabaseClient'; +import { getCheckoutMode } from '@app/utils/protocolDetection'; + +export interface PlanFeature { + name: string; + included: boolean; +} + +export interface PlanTier { + id: string; + name: string; + price: number; + currency: string; + period: string; + popular?: boolean; + features: PlanFeature[]; + highlights: string[]; + isContactOnly?: boolean; + seatPrice?: number; // Per-seat price for enterprise plans + requiresSeats?: boolean; // Flag indicating seat selection is needed + lookupKey: string; // Stripe lookup key for this plan +} + +export interface PlanTierGroup { + tier: 'free' | 'server' | 'enterprise'; + name: string; + monthly: PlanTier | null; + yearly: PlanTier | null; + features: PlanFeature[]; + highlights: string[]; + popular?: boolean; +} + +export interface PlansResponse { + plans: PlanTier[]; +} + +export interface CheckoutSessionRequest { + lookup_key: string; // Stripe lookup key (e.g., 'selfhosted:server:monthly') + installation_id?: string; // Installation ID from backend (MAC-based fingerprint) + current_license_key?: string; // Current license key for upgrades + requires_seats?: boolean; // Whether to add adjustable seat pricing + seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI) + successUrl?: string; + cancelUrl?: string; +} + +export interface CheckoutSessionResponse { + clientSecret: string; + sessionId: string; + url?: string; // URL for hosted checkout (when not using HTTPS) +} + +export interface BillingPortalResponse { + url: string; +} + +export interface InstallationIdResponse { + installationId: string; +} + +export interface LicenseKeyResponse { + status: 'ready' | 'pending'; + license_key?: string; + email?: string; + plan?: string; +} + +export interface LicenseInfo { + licenseType: 'NORMAL' | 'PRO' | 'ENTERPRISE'; + enabled: boolean; + maxUsers: number; + hasKey: boolean; + licenseKey?: string; // The actual license key (for upgrades) +} + +export interface LicenseSaveResponse { + success: boolean; + licenseType?: string; + message?: string; + error?: string; +} + +// Currency symbol mapping +const getCurrencySymbol = (currency: string): string => { + const currencySymbols: { [key: string]: string } = { + 'gbp': '£', + 'usd': '$', + 'eur': '€', + 'cny': '¥', + 'inr': '₹', + 'brl': 'R$', + 'idr': 'Rp' + }; + return currencySymbols[currency.toLowerCase()] || currency.toUpperCase(); +}; + +// Self-hosted plan lookup keys +const SELF_HOSTED_LOOKUP_KEYS = [ + 'selfhosted:server:monthly', + 'selfhosted:server:yearly', + 'selfhosted:enterpriseseat:monthly', + 'selfhosted:enterpriseseat:yearly', +]; + +const licenseService = { + /** + * Get available plans with pricing for the specified currency + */ + async getPlans(currency: string = 'gbp'): Promise { + try { + // Fetch all self-hosted prices from Stripe + const { data, error } = await supabase.functions.invoke<{ + prices: Record; + missing: string[]; + }>('stripe-price-lookup', { + body: { + lookup_keys: SELF_HOSTED_LOOKUP_KEYS, + currency + }, + }); + + if (error) { + throw new Error(`Failed to fetch plans: ${error.message}`); + } + + if (!data || !data.prices) { + throw new Error('No pricing data returned'); + } + + // Log missing prices for debugging + if (data.missing && data.missing.length > 0) { + console.warn('Missing Stripe prices for lookup keys:', data.missing, 'in currency:', currency); + } + + // Build price map for easy access + const priceMap = new Map(); + for (const [lookupKey, priceData] of Object.entries(data.prices)) { + priceMap.set(lookupKey, { + unit_amount: priceData.unit_amount, + currency: priceData.currency + }); + } + + const currencySymbol = getCurrencySymbol(currency); + + // Helper to get price info + const getPriceInfo = (lookupKey: string, fallback: number = 0) => { + const priceData = priceMap.get(lookupKey); + return priceData ? priceData.unit_amount / 100 : fallback; + }; + + // Build plan tiers + const plans: PlanTier[] = [ + { + id: 'selfhosted:server:monthly', + lookupKey: 'selfhosted:server:monthly', + name: 'Server - Monthly', + price: getPriceInfo('selfhosted:server:monthly'), + currency: currencySymbol, + period: '/month', + popular: false, + features: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Unlimited users', included: true }, + { name: 'Community support', included: true }, + { name: 'Regular updates', included: true }, + { name: 'Priority support', included: false }, + { name: 'Custom integrations', included: false }, + ], + highlights: [ + 'Self-hosted on your infrastructure', + 'All features included', + 'Cancel anytime' + ] + }, + { + id: 'selfhosted:server:yearly', + lookupKey: 'selfhosted:server:yearly', + name: 'Server - Yearly', + price: getPriceInfo('selfhosted:server:yearly'), + currency: currencySymbol, + period: '/year', + popular: true, + features: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Unlimited users', included: true }, + { name: 'Community support', included: true }, + { name: 'Regular updates', included: true }, + { name: 'Priority support', included: false }, + { name: 'Custom integrations', included: false }, + ], + highlights: [ + 'Self-hosted on your infrastructure', + 'All features included', + 'Save with annual billing' + ] + }, + { + id: 'selfhosted:enterprise:monthly', + lookupKey: 'selfhosted:server:monthly', + name: 'Enterprise - Monthly', + price: getPriceInfo('selfhosted:server:monthly'), + seatPrice: getPriceInfo('selfhosted:enterpriseseat:monthly'), + currency: currencySymbol, + period: '/month', + popular: false, + requiresSeats: true, + features: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Per-seat licensing', included: true }, + { name: 'Priority support', included: true }, + { name: 'SLA guarantee', included: true }, + { name: 'Custom integrations', included: true }, + { name: 'Dedicated account manager', included: true }, + ], + highlights: [ + 'Enterprise-grade support', + 'Custom integrations available', + 'SLA guarantee included' + ] + }, + { + id: 'selfhosted:enterprise:yearly', + lookupKey: 'selfhosted:server:yearly', + name: 'Enterprise - Yearly', + price: getPriceInfo('selfhosted:server:yearly'), + seatPrice: getPriceInfo('selfhosted:enterpriseseat:yearly'), + currency: currencySymbol, + period: '/year', + popular: false, + requiresSeats: true, + features: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Per-seat licensing', included: true }, + { name: 'Priority support', included: true }, + { name: 'SLA guarantee', included: true }, + { name: 'Custom integrations', included: true }, + { name: 'Dedicated account manager', included: true }, + ], + highlights: [ + 'Enterprise-grade support', + 'Custom integrations available', + 'Save with annual billing' + ] + }, + ]; + + // Filter out plans with missing prices (price === 0 means Stripe price not found) + const validPlans = plans.filter(plan => plan.price > 0); + + if (validPlans.length < plans.length) { + const missingPlans = plans.filter(plan => plan.price === 0).map(p => p.id); + console.warn('Filtered out plans with missing prices:', missingPlans); + } + + // Add Free plan (static definition) + const freePlan: PlanTier = { + id: 'free', + lookupKey: 'free', + name: 'Free', + price: 0, + currency: currencySymbol, + period: '', + popular: false, + features: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Up to 5 users', included: true }, + { name: 'Community support', included: true }, + { name: 'Regular updates', included: true }, + { name: 'Priority support', included: false }, + { name: 'SLA guarantee', included: false }, + { name: 'Custom integrations', included: false }, + { name: 'Dedicated account manager', included: false }, + ], + highlights: [ + 'Up to 5 users', + 'Self-hosted', + 'All basic features' + ] + }; + + const allPlans = [freePlan, ...validPlans]; + + return { + plans: allPlans + }; + } catch (error) { + console.error('Error fetching plans:', error); + throw error; + } + }, + + /** + * Group plans by tier for display (Free, Server, Enterprise) + */ + groupPlansByTier(plans: PlanTier[]): PlanTierGroup[] { + const groups: PlanTierGroup[] = []; + + // Free tier + const freePlan = plans.find(p => p.id === 'free'); + if (freePlan) { + groups.push({ + tier: 'free', + name: 'Free', + monthly: freePlan, + yearly: null, + features: freePlan.features, + highlights: freePlan.highlights, + popular: false, + }); + } + + // Server tier + const serverMonthly = plans.find(p => p.lookupKey === 'selfhosted:server:monthly'); + const serverYearly = plans.find(p => p.lookupKey === 'selfhosted:server:yearly'); + if (serverMonthly || serverYearly) { + groups.push({ + tier: 'server', + name: 'Server', + monthly: serverMonthly || null, + yearly: serverYearly || null, + features: (serverMonthly || serverYearly)!.features, + highlights: (serverMonthly || serverYearly)!.highlights, + popular: serverYearly?.popular || serverMonthly?.popular || false, + }); + } + + // Enterprise tier (uses server pricing + seats) + const enterpriseMonthly = plans.find(p => p.id === 'selfhosted:enterprise:monthly'); + const enterpriseYearly = plans.find(p => p.id === 'selfhosted:enterprise:yearly'); + if (enterpriseMonthly || enterpriseYearly) { + groups.push({ + tier: 'enterprise', + name: 'Enterprise', + monthly: enterpriseMonthly || null, + yearly: enterpriseYearly || null, + features: (enterpriseMonthly || enterpriseYearly)!.features, + highlights: (enterpriseMonthly || enterpriseYearly)!.highlights, + popular: false, + }); + } + + return groups; + }, + + /** + * Create a Stripe checkout session for upgrading + */ + async createCheckoutSession(request: CheckoutSessionRequest): Promise { + // Detect if HTTPS is available to determine checkout mode + const checkoutMode = getCheckoutMode(); + const baseUrl = window.location.origin; + const settingsUrl = `${baseUrl}/settings/adminPlan`; + + const { data, error } = await supabase.functions.invoke('create-checkout', { + body: { + self_hosted: true, + lookup_key: request.lookup_key, + installation_id: request.installation_id, + current_license_key: request.current_license_key, + requires_seats: request.requires_seats, + seat_count: request.seat_count || 1, + callback_base_url: baseUrl, + ui_mode: checkoutMode, + // For hosted checkout, provide success/cancel URLs + success_url: checkoutMode === 'hosted' + ? `${settingsUrl}?session_id={CHECKOUT_SESSION_ID}&payment_status=success` + : undefined, + cancel_url: checkoutMode === 'hosted' + ? `${settingsUrl}?payment_status=canceled` + : undefined, + }, + }); + + if (error) { + throw new Error(`Failed to create checkout session: ${error.message}`); + } + + return data as CheckoutSessionResponse; + }, + + /** + * Create a Stripe billing portal session for managing subscription + * Uses license key for self-hosted authentication + */ + async createBillingPortalSession(returnUrl: string, licenseKey: string): Promise { + const { data, error} = await supabase.functions.invoke('manage-billing', { + body: { + return_url: returnUrl, + license_key: licenseKey, + self_hosted: true // Explicitly indicate self-hosted mode + }, + }); + + if (error) { + throw new Error(`Failed to create billing portal session: ${error.message}`); + } + + return data as BillingPortalResponse; + }, + + /** + * Get the installation ID from the backend (MAC-based fingerprint) + */ + async getInstallationId(): Promise { + try { + const response = await apiClient.get('/api/v1/admin/installation-id'); + + const data: InstallationIdResponse = await response.data; + return data.installationId; + } catch (error) { + console.error('Error fetching installation ID:', error); + throw error; + } + }, + + /** + * Check if license key is ready for the given installation ID + */ + async checkLicenseKey(installationId: string): Promise { + const { data, error } = await supabase.functions.invoke('get-license-key', { + body: { + installation_id: installationId, + }, + }); + + if (error) { + throw new Error(`Failed to check license key: ${error.message}`); + } + + return data as LicenseKeyResponse; + }, + + /** + * Save license key to backend + */ + async saveLicenseKey(licenseKey: string): Promise { + try { + const response = await apiClient.post('/api/v1/admin/license-key', { + licenseKey: licenseKey, + }); + + return response.data; + } catch (error) { + console.error('Error saving license key:', error); + throw error; + } + }, + + /** + * Get current license information from backend + */ + async getLicenseInfo(): Promise { + try { + const response = await apiClient.get('/api/v1/admin/license-info'); + return response.data; + } catch (error) { + console.error('Error fetching license info:', error); + 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; + } + }, +}; + +/** + * Map license type to plan tier + * @param licenseInfo - Current license information + * @returns Plan tier: 'free' | 'server' | 'enterprise' + */ +export const mapLicenseToTier = (licenseInfo: LicenseInfo | null): 'free' | 'server' | 'enterprise' | null => { + if (!licenseInfo) return null; + + // No license or NORMAL type = Free tier + if (licenseInfo.licenseType === 'NORMAL' || !licenseInfo.enabled) { + return 'free'; + } + + // PRO type (no seats) = Server tier + if (licenseInfo.licenseType === 'PRO') { + return 'server'; + } + + // ENTERPRISE type (with seats) = Enterprise tier + if (licenseInfo.licenseType === 'ENTERPRISE' && licenseInfo.maxUsers > 0) { + return 'enterprise'; + } + + // Default fallback + return 'free'; +}; + +export default licenseService; diff --git a/frontend/src/proprietary/utils/licenseCheckoutUtils.ts b/frontend/src/proprietary/utils/licenseCheckoutUtils.ts new file mode 100644 index 000000000..6235bbfe9 --- /dev/null +++ b/frontend/src/proprietary/utils/licenseCheckoutUtils.ts @@ -0,0 +1,265 @@ +/** + * Shared utilities for license checkout completion + * Used by both embedded and hosted checkout flows + */ + +import licenseService, { LicenseInfo } from '@app/services/licenseService'; + +/** + * Result of license key polling + */ +export interface LicenseKeyPollResult { + success: boolean; + licenseKey?: string; + error?: string; + timedOut?: boolean; +} + +/** + * Configuration for license key polling + */ +export interface PollConfig { + /** Check if component is still mounted (prevents state updates after unmount) */ + isMounted?: () => boolean; + /** Callback for status changes during polling */ + onStatusChange?: (status: 'polling' | 'ready' | 'timeout') => void; + /** Custom backoff intervals in milliseconds (default: [1000, 2000, 4000, 8000, 16000]) */ + backoffMs?: number[]; +} + +/** + * Poll for license key with exponential backoff + * Consolidates polling logic used by both embedded and hosted checkout + */ +export async function pollLicenseKeyWithBackoff( + installationId: string, + config: PollConfig = {} +): Promise { + const { + isMounted = () => true, + onStatusChange, + backoffMs = [1000, 2000, 4000, 8000, 16000], + } = config; + + let attemptIndex = 0; + + onStatusChange?.('polling'); + console.log(`Starting license key polling for installation: ${installationId}`); + + const poll = async (): Promise => { + // Check if component is still mounted + if (!isMounted()) { + console.log('Polling cancelled: component unmounted'); + return { success: false, error: 'Component unmounted' }; + } + + const attemptNumber = attemptIndex + 1; + console.log(`Polling attempt ${attemptNumber}/${backoffMs.length}`); + + try { + const response = await licenseService.checkLicenseKey(installationId); + + // Check mounted after async operation + if (!isMounted()) { + return { success: false, error: 'Component unmounted' }; + } + + if (response.status === 'ready' && response.license_key) { + console.log('✅ License key ready!'); + onStatusChange?.('ready'); + return { + success: true, + licenseKey: response.license_key, + }; + } + + // License not ready yet, continue polling + attemptIndex++; + + if (attemptIndex >= backoffMs.length) { + console.warn('⏱️ License polling timeout after all attempts'); + onStatusChange?.('timeout'); + return { + success: false, + timedOut: true, + error: 'Polling timeout - license key not ready', + }; + } + + // Wait before next attempt + const nextDelay = backoffMs[attemptIndex]; + console.log(`Retrying in ${nextDelay}ms...`); + await new Promise(resolve => setTimeout(resolve, nextDelay)); + + return poll(); + } catch (error) { + console.error(`Polling attempt ${attemptNumber} failed:`, error); + + if (!isMounted()) { + return { success: false, error: 'Component unmounted' }; + } + + attemptIndex++; + + if (attemptIndex >= backoffMs.length) { + console.error('Polling failed after all attempts'); + onStatusChange?.('timeout'); + return { + success: false, + error: error instanceof Error ? error.message : 'Polling failed', + }; + } + + // Retry with exponential backoff even on error + const nextDelay = backoffMs[attemptIndex]; + console.log(`Retrying after error in ${nextDelay}ms...`); + await new Promise(resolve => setTimeout(resolve, nextDelay)); + + return poll(); + } + }; + + return poll(); +} + +/** + * Result of license key activation + */ +export interface LicenseActivationResult { + success: boolean; + licenseType?: string; + licenseInfo?: LicenseInfo; + error?: string; +} + +/** + * Activate a license key by saving it to the backend and fetching updated info + * Used for NEW subscriptions where we have a new license key to save + */ +export async function activateLicenseKey( + licenseKey: string, + options: { + /** Check if component is still mounted */ + isMounted?: () => boolean; + /** Callback when license is activated with updated info */ + onActivated?: (licenseInfo: LicenseInfo) => void; + } = {} +): Promise { + const { isMounted = () => true, onActivated } = options; + + try { + console.log('Activating license key...'); + const saveResponse = await licenseService.saveLicenseKey(licenseKey); + + if (!isMounted()) { + return { success: false, error: 'Component unmounted' }; + } + + if (saveResponse.success) { + console.log(`License key activated: ${saveResponse.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: saveResponse.licenseType, + licenseInfo, + }; + } catch (infoError) { + console.error('Error fetching license info after activation:', infoError); + // Still return success since save succeeded + return { + success: true, + licenseType: saveResponse.licenseType, + error: 'Failed to fetch updated license info', + }; + } + } else { + console.error('Failed to save license key:', saveResponse.error); + return { + success: false, + error: saveResponse.error || 'Failed to save license key', + }; + } + } catch (error) { + console.error('Error activating license key:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Activation failed', + }; + } +} + +/** + * 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 new file mode 100644 index 000000000..9bd9ce03b --- /dev/null +++ b/frontend/src/proprietary/utils/protocolDetection.ts @@ -0,0 +1,43 @@ +/** + * Protocol detection utility for determining secure context + * Used to decide between Embedded Checkout (HTTPS) and Hosted Checkout (HTTP) + */ + +/** + * Check if the current context is secure (HTTPS or localhost) + * @returns true if HTTPS or localhost, false if HTTP + */ +export function isSecureContext(): boolean { + // Allow localhost for development (works with both HTTP and HTTPS) + if (typeof window !== 'undefined') { + // const hostname = window.location.hostname; + const protocol = window.location.protocol; + + // Localhost is considered secure for development + // if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') { + // return true; + // } + + // Check if HTTPS + return protocol === 'https:'; + } + + // Default to false if window is not available (SSR context) + return false; +} + +/** + * Get the appropriate Stripe checkout UI mode based on current context + * @returns 'embedded' for HTTPS/localhost, 'hosted' for HTTP + */ +export function getCheckoutMode(): 'embedded' | 'hosted' { + return isSecureContext() ? 'embedded' : 'hosted'; +} + +/** + * Check if Embedded Checkout can be used in current context + * @returns true if secure context (HTTPS/localhost) + */ +export function canUseEmbeddedCheckout(): boolean { + return isSecureContext(); +} diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts index ca36e9027..f483e59d4 100644 --- a/frontend/vite-env.d.ts +++ b/frontend/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { readonly VITE_PUBLIC_POSTHOG_KEY: string; readonly VITE_PUBLIC_POSTHOG_HOST: string; + readonly VITE_SUPABASE_URL: string; + readonly VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: string; } interface ImportMeta {