From 6177ccd3335e7e43a073994dbc5ed5133cd0fae7 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:54:33 +0000 Subject: [PATCH 1/8] update notif (#4937) # Description of Changes image image image --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../software/SPDF/config/WebMvcConfig.java | 48 +- .../controller/api/misc/ConfigController.java | 19 + .../public/locales/en-GB/translation.json | 41 +- .../core/components/shared/UpdateModal.tsx | 415 ++++++++++++++++++ .../config/configSections/GeneralSection.tsx | 138 +++++- .../src/core/contexts/AppConfigContext.tsx | 3 + frontend/src/core/services/updateService.ts | 186 ++++++++ frontend/src/core/styles/zIndex.ts | 2 +- 8 files changed, 844 insertions(+), 8 deletions(-) create mode 100644 frontend/src/core/components/shared/UpdateModal.tsx create mode 100644 frontend/src/core/services/updateService.ts 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..b6be63ec5 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 @@ -63,13 +63,51 @@ 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") + .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") + .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/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c9cd9c692..598ea2ee0 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", diff --git a/frontend/src/core/components/shared/UpdateModal.tsx b/frontend/src/core/components/shared/UpdateModal.tsx new file mode 100644 index 000000000..937f4426d --- /dev/null +++ b/frontend/src/core/components/shared/UpdateModal.tsx @@ -0,0 +1,415 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from '@app/services/updateService'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import DownloadIcon from '@mui/icons-material/Download'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; + +interface UpdateModalProps { + opened: boolean; + onClose: () => void; + currentVersion: string; + updateSummary: UpdateSummary; + machineInfo: MachineInfo; +} + +const UpdateModal: React.FC = ({ + opened, + onClose, + currentVersion, + updateSummary, + machineInfo, +}) => { + const { t } = useTranslation(); + const [fullUpdateInfo, setFullUpdateInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [expandedVersions, setExpandedVersions] = useState>(new Set([0])); + + useEffect(() => { + if (opened) { + setLoading(true); + setExpandedVersions(new Set([0])); + updateService.getFullUpdateInfo(currentVersion, machineInfo).then((info) => { + setFullUpdateInfo(info); + setLoading(false); + }); + } + }, [opened, currentVersion, machineInfo]); + + const toggleVersion = (index: number) => { + setExpandedVersions((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const getPriorityColor = (priority: string): string => { + switch (priority?.toLowerCase()) { + case 'urgent': + return 'red'; + case 'normal': + return 'blue'; + case 'minor': + return 'cyan'; + case 'low': + return 'gray'; + default: + return 'gray'; + } + }; + + const getPriorityLabel = (priority: string): string => { + const key = priority?.toLowerCase(); + return t(`update.priority.${key}`, priority || 'Normal'); + }; + + const downloadUrl = updateService.getDownloadUrl(machineInfo); + + return ( + + {t('update.modalTitle', 'Update Available')} + + } + centered + size="xl" + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + styles={{ + body: { + maxHeight: '75vh', + overflowY: 'auto', + }, + }} + > + + {/* Version Summary Section */} + + + + + {t('update.current', 'Current Version')} + + + {currentVersion} + + + + + + {t('update.priorityLabel', 'Priority')} + + + {getPriorityLabel(updateSummary.max_priority)} + + + + + + {t('update.latest', 'Latest Version')} + + + {updateSummary.latest_version} + + + + + {updateSummary.latest_stable_version && ( + + + + {t('update.latestStable', 'Latest Stable')}: + + + {updateSummary.latest_stable_version} + + + + )} + + + {/* Recommended action */} + {updateSummary.recommended_action && ( + + + + + + {t('update.recommendedAction', 'Recommended Action')} + + + {updateSummary.recommended_action} + + + + + )} + + {/* Breaking changes warning */} + {updateSummary.any_breaking && ( + + + + + + {t('update.breakingChangesDetected', 'Breaking Changes Detected')} + + + {t( + 'update.breakingChangesMessage', + 'Some versions contain breaking changes. Please review the migration guides below before updating.' + )} + + + + + )} + + {/* Migration guides */} + {updateSummary.migration_guides && updateSummary.migration_guides.length > 0 && ( + <> + + + + {t('update.migrationGuides', 'Migration Guides')} + + {updateSummary.migration_guides.map((guide, idx) => ( + + + + + {t('update.version', 'Version')} {guide.version} + + + {guide.notes} + + + + + + ))} + + + )} + + {/* Version details */} + + {loading ? ( +
+ + + + {t('update.loadingDetailedInfo', 'Loading detailed information...')} + + +
+ ) : fullUpdateInfo && fullUpdateInfo.new_versions && fullUpdateInfo.new_versions.length > 0 ? ( + + + + {t('update.availableUpdates', 'Available Updates')} + + + {fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'} + + + + {fullUpdateInfo.new_versions.map((version, index) => { + const isExpanded = expandedVersions.has(index); + return ( + + toggleVersion(index)} + > + + + + {t('update.version', 'Version')} + + + {version.version} + + + + {getPriorityLabel(version.priority)} + + + + + {isExpanded ? ( + + ) : ( + + )} + + + + + + + + + {version.announcement.title} + + + {version.announcement.message} + + + + {version.compatibility.breaking_changes && ( + + + + + {t('update.breakingChanges', 'Breaking Changes')} + + + + {version.compatibility.breaking_description || + t('update.breakingChangesDefault', 'This version contains breaking changes.')} + + {version.compatibility.migration_guide_url && ( + + )} + + )} + + + + + ); + })} + + + ) : null} + + {/* Action buttons */} + + + + + {downloadUrl && ( + + )} + +
+
+ ); +}; + +export default UpdateModal; diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index de9994705..e80a1fb07 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -1,10 +1,12 @@ import React, { useState, useEffect } from 'react'; -import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core'; +import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon, Button, Badge, Alert } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePreferences } from '@app/contexts/PreferencesContext'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import type { ToolPanelMode } from '@app/constants/toolPanel'; import LocalIcon from '@app/components/shared/LocalIcon'; +import { updateService, UpdateSummary } from '@app/services/updateService'; +import UpdateModal from '@app/components/shared/UpdateModal'; const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4; const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed'; @@ -22,12 +24,44 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => // Check localStorage on mount return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true'; }); + const [updateSummary, setUpdateSummary] = useState(null); + const [updateModalOpened, setUpdateModalOpened] = useState(false); + const [checkingUpdate, setCheckingUpdate] = useState(false); // Sync local state with preference changes useEffect(() => { setFileLimitInput(preferences.autoUnzipFileLimit); }, [preferences.autoUnzipFileLimit]); + // Check for updates on mount + useEffect(() => { + if (config?.appVersion && config?.machineType) { + checkForUpdate(); + } + }, [config?.appVersion, config?.machineType]); + + const checkForUpdate = async () => { + if (!config?.appVersion || !config?.machineType) { + return; + } + + setCheckingUpdate(true); + const machineInfo = { + machineType: config.machineType, + activeSecurity: config.activeSecurity ?? false, + licenseType: config.license ?? 'NORMAL', + }; + + const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo); + if (summary) { + const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0; + if (isNewerVersion) { + setUpdateSummary(summary); + } + } + setCheckingUpdate(false); + }; + // Check if login is disabled const loginDisabled = !config?.enableLogin; @@ -170,6 +204,108 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => + + {/* Update Check Section */} + {config?.appVersion && ( + + +
+ +
+ + {t('settings.general.updates.title', 'Software Updates')} + + + {t('settings.general.updates.description', 'Check for updates and view version information')} + +
+ {updateSummary && ( + + {updateSummary.max_priority === 'urgent' + ? t('update.urgentUpdateAvailable', 'Urgent Update') + : t('update.updateAvailable', 'Update Available')} + + )} +
+
+ + +
+ + {t('settings.general.updates.currentVersion', 'Current Version')}:{' '} + + {config.appVersion} + + + {updateSummary && ( + + {t('settings.general.updates.latestVersion', 'Latest Version')}:{' '} + + {updateSummary.latest_version} + + + )} +
+ + + {updateSummary && ( + + )} + +
+ + {updateSummary?.any_breaking && ( + + + {t( + 'update.breakingChangesMessage', + 'Some versions contain breaking changes. Please review the migration guides before updating.' + )} + + + )} +
+
+ )} + + {/* Update Modal */} + {updateSummary && config?.appVersion && config?.machineType && ( + setUpdateModalOpened(false)} + currentVersion={config.appVersion} + updateSummary={updateSummary} + machineInfo={{ + machineType: config.machineType, + activeSecurity: config.activeSecurity ?? false, + licenseType: config.license ?? 'NORMAL', + }} + /> + )} ); }; diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index ad47b415b..2f672da26 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -39,6 +39,9 @@ export interface AppConfig { license?: string; SSOAutoLogin?: boolean; serverCertificateEnabled?: boolean; + appVersion?: string; + machineType?: string; + activeSecurity?: boolean; error?: string; } diff --git a/frontend/src/core/services/updateService.ts b/frontend/src/core/services/updateService.ts new file mode 100644 index 000000000..1cba4838f --- /dev/null +++ b/frontend/src/core/services/updateService.ts @@ -0,0 +1,186 @@ +export interface UpdateSummary { + latest_version: string; + latest_stable_version?: string; + max_priority: 'urgent' | 'normal' | 'minor' | 'low'; + recommended_action?: string; + any_breaking: boolean; + migration_guides?: Array<{ + version: string; + notes: string; + url: string; + }>; +} + +export interface VersionUpdate { + version: string; + priority: 'urgent' | 'normal' | 'minor' | 'low'; + announcement: { + title: string; + message: string; + }; + compatibility: { + breaking_changes: boolean; + breaking_description?: string; + migration_guide_url?: string; + }; +} + +export interface FullUpdateInfo { + latest_version: string; + latest_stable_version?: string; + new_versions: VersionUpdate[]; +} + +export interface MachineInfo { + machineType: string; + activeSecurity: boolean; + licenseType: string; +} + +export class UpdateService { + private readonly baseUrl = 'https://supabase.stirling.com/functions/v1/updates'; + + /** + * Compare two version strings + * @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ + compareVersions(version1: string, version2: string): number { + const v1 = version1.split('.'); + const v2 = version2.split('.'); + + for (let i = 0; i < v1.length || i < v2.length; i++) { + const n1 = parseInt(v1[i]) || 0; + const n2 = parseInt(v2[i]) || 0; + + if (n1 > n2) { + return 1; + } else if (n1 < n2) { + return -1; + } + } + + return 0; + } + + /** + * Get download URL based on machine type and security settings + */ + getDownloadUrl(machineInfo: MachineInfo): string | null { + // Only show download for non-Docker installations + if (machineInfo.machineType === 'Docker' || machineInfo.machineType === 'Kubernetes') { + return null; + } + + const baseUrl = 'https://files.stirlingpdf.com/'; + + // Determine file based on machine type and security + if (machineInfo.machineType === 'Server-jar') { + return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar'); + } + + // Client installations + if (machineInfo.machineType.startsWith('Client-')) { + const os = machineInfo.machineType.replace('Client-', ''); // win, mac, unix + const type = machineInfo.activeSecurity ? '-server-security' : '-server'; + + if (os === 'unix') { + return baseUrl + os + type + '.jar'; + } else if (os === 'win') { + return baseUrl + os + '-installer.exe'; + } else if (os === 'mac') { + return baseUrl + os + '-installer.dmg'; + } + } + + return null; + } + + /** + * Fetch update summary from API + */ + async getUpdateSummary(currentVersion: string, machineInfo: MachineInfo): Promise { + // Map Java License enum to API types + let type = 'normal'; + if (machineInfo.licenseType === 'PRO') { + type = 'pro'; + } else if (machineInfo.licenseType === 'ENTERPRISE') { + type = 'enterprise'; + } + + const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=true`; + console.log('Fetching update summary from:', url); + + try { + const response = await fetch(url); + console.log('Response status:', response.status); + + if (response.status === 200) { + const data = await response.json(); + return data as UpdateSummary; + } else { + console.error('Failed to fetch update summary from Supabase:', response.status); + return null; + } + } catch (error) { + console.error('Failed to fetch update summary from Supabase:', error); + return null; + } + } + + /** + * Fetch full update information with detailed version info + */ + async getFullUpdateInfo(currentVersion: string, machineInfo: MachineInfo): Promise { + // Map Java License enum to API types + let type = 'normal'; + if (machineInfo.licenseType === 'PRO') { + type = 'pro'; + } else if (machineInfo.licenseType === 'ENTERPRISE') { + type = 'enterprise'; + } + + const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=false`; + console.log('Fetching full update info from:', url); + + try { + const response = await fetch(url); + console.log('Full update response status:', response.status); + + if (response.status === 200) { + const data = await response.json(); + return data as FullUpdateInfo; + } else { + console.error('Failed to fetch full update info from Supabase:', response.status); + return null; + } + } catch (error) { + console.error('Failed to fetch full update info from Supabase:', error); + return null; + } + } + + /** + * Get current version from GitHub build.gradle as fallback + */ + async getCurrentVersionFromGitHub(): Promise { + const url = 'https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle'; + + try { + const response = await fetch(url); + if (response.status === 200) { + const text = await response.text(); + const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/; + const match = versionRegex.exec(text); + if (match) { + return match[1]; + } + } + throw new Error('Version number not found'); + } catch (error) { + console.error('Failed to fetch latest version from build.gradle:', error); + return ''; + } + } +} + +export const updateService = new UpdateService(); diff --git a/frontend/src/core/styles/zIndex.ts b/frontend/src/core/styles/zIndex.ts index aeb69e9b5..1057bf2f2 100644 --- a/frontend/src/core/styles/zIndex.ts +++ b/frontend/src/core/styles/zIndex.ts @@ -15,7 +15,7 @@ export const Z_INDEX_HOVER_ACTION_MENU = 100; export const Z_INDEX_SELECTION_BOX = 1000; export const Z_INDEX_DROP_INDICATOR = 1001; export const Z_INDEX_DRAG_BADGE = 1001; -// Modal that appears on top of config modal (e.g., restart confirmation) +// Modal that appears on top of config modal (e.g., restart confirmation, update modal) export const Z_INDEX_OVER_CONFIG_MODAL = 2000; // Toast notifications and error displays - Always on top (higher than rainbow theme at 10000) From 8ffe53536d25e471878ba17b2dda404233c4de60 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:35:35 +0000 Subject: [PATCH 2/8] activeUsers (#4936) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../SPDF/config/WAUTrackingFilter.java | 49 +++++++++ .../software/SPDF/config/WebMvcConfig.java | 26 ++++- .../controller/web/MetricsController.java | 31 ++++++ .../service/WeeklyActiveUsersService.java | 100 ++++++++++++++++++ frontend/src/core/services/apiClientSetup.ts | 13 ++- frontend/src/core/utils/browserIdentifier.ts | 46 ++++++++ 6 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java create mode 100644 frontend/src/core/utils/browserIdentifier.ts 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 b6be63ec5..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) { @@ -73,7 +89,8 @@ public class WebMvcConfig implements WebMvcConfigurer { "Origin", "X-API-KEY", "X-CSRF-TOKEN", - "X-XSRF-TOKEN") + "X-XSRF-TOKEN", + "X-Browser-Id") .exposedHeaders( "WWW-Authenticate", "X-Total-Count", @@ -98,7 +115,8 @@ public class WebMvcConfig implements WebMvcConfigurer { "Origin", "X-API-KEY", "X-CSRF-TOKEN", - "X-XSRF-TOKEN") + "X-XSRF-TOKEN", + "X-Browser-Id") .exposedHeaders( "WWW-Authenticate", "X-Total-Count", 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/frontend/src/core/services/apiClientSetup.ts b/frontend/src/core/services/apiClientSetup.ts index f7de717a3..07bc6782b 100644 --- a/frontend/src/core/services/apiClientSetup.ts +++ b/frontend/src/core/services/apiClientSetup.ts @@ -1,5 +1,14 @@ import { AxiosInstance } from 'axios'; +import { getBrowserId } from '@app/utils/browserIdentifier'; -export function setupApiInterceptors(_client: AxiosInstance): void { - // Core version: no interceptors to add +export function setupApiInterceptors(client: AxiosInstance): void { + // Add browser ID header for WAU tracking + client.interceptors.request.use( + (config) => { + const browserId = getBrowserId(); + config.headers['X-Browser-Id'] = browserId; + return config; + }, + (error) => Promise.reject(error) + ); } diff --git a/frontend/src/core/utils/browserIdentifier.ts b/frontend/src/core/utils/browserIdentifier.ts new file mode 100644 index 000000000..b41914a5e --- /dev/null +++ b/frontend/src/core/utils/browserIdentifier.ts @@ -0,0 +1,46 @@ +/** + * Browser identifier utility for anonymous usage tracking + * Generates and persists a unique UUID in localStorage for WAU tracking + */ + +const BROWSER_ID_KEY = 'stirling_browser_id'; + +/** + * Gets or creates a unique browser identifier + * Used for Weekly Active Users (WAU) tracking in no-login mode + */ +export function getBrowserId(): string { + try { + // Try to get existing ID from localStorage + let browserId = localStorage.getItem(BROWSER_ID_KEY); + + if (!browserId) { + // Generate new UUID v4 + browserId = generateUUID(); + localStorage.setItem(BROWSER_ID_KEY, browserId); + } + + return browserId; + } catch (error) { + // Fallback to session-based ID if localStorage is unavailable + console.warn('localStorage unavailable, using session-based ID', error); + return `session_${generateUUID()}`; + } +} + +/** + * Generates a UUID v4 + */ +function generateUUID(): string { + // Use crypto.randomUUID if available (modern browsers) + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // Fallback to manual UUID generation + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} From 0f73a1cf13bcf0e432602a7079bfeadbe28ebc05 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:53:25 +0000 Subject: [PATCH 3/8] Feature/sign placement UI (#4891) Show signature preview on hover Place signature smaller (mkatch preview size) Retain signature in modal on close/open Clean up ui generally Small bug fixes --- .../public/locales/en-GB/translation.json | 36 +- .../annotation/shared/BaseAnnotationTool.tsx | 31 +- .../annotation/shared/ColorPicker.tsx | 12 +- .../annotation/shared/DrawingCanvas.tsx | 185 ++--- .../annotation/shared/DrawingControls.tsx | 66 +- .../annotation/shared/TextInputWithFont.tsx | 46 +- .../components/tools/sign/SignSettings.tsx | 661 +++++++++++++----- .../core/components/viewer/EmbedPdfViewer.tsx | 32 +- .../components/viewer/HistoryAPIBridge.tsx | 61 +- .../components/viewer/SignatureAPIBridge.tsx | 294 ++++++-- .../viewer/SignaturePlacementOverlay.tsx | 171 +++++ .../core/components/viewer/ZoomAPIBridge.tsx | 2 +- .../src/core/components/viewer/viewerTypes.ts | 1 + frontend/src/core/constants/signConstants.ts | 15 + .../src/core/contexts/SignatureContext.tsx | 26 + .../core/hooks/tools/sign/useSignOperation.ts | 4 +- .../hooks/tools/sign/useSignParameters.ts | 4 +- frontend/src/core/hooks/useTooltipPosition.ts | 4 +- frontend/src/core/tools/Sign.tsx | 30 +- frontend/src/core/utils/signaturePreview.ts | 84 +++ 20 files changed, 1404 insertions(+), 361 deletions(-) create mode 100644 frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx create mode 100644 frontend/src/core/constants/signConstants.ts create mode 100644 frontend/src/core/utils/signaturePreview.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 598ea2ee0..980760e3a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -2134,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", @@ -2159,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", @@ -2175,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", @@ -4778,6 +4802,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", @@ -4793,7 +4820,8 @@ "used": "used", "available": "available", "cancel": "Cancel", - "preview": "Preview" + "preview": "Preview", + "done": "Done" }, "config": { "overview": { 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 and Supabase is configured */} + {licenseInfo?.licenseKey && isSupabaseConfigured && ( + + + {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..2c0640a3f --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx @@ -0,0 +1,109 @@ +import React, { useState, useMemo } from 'react'; +import { Button, 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'; +import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable'; + +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) => ( + + ))} +
+ +
+ +
+ + + + +
+ ); +}; + +export default AvailablePlansSection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx new file mode 100644 index 000000000..129d59b9f --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { Card, Badge, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { PlanFeature } from '@app/services/licenseService'; + +interface PlanWithFeatures { + name: string; + features: PlanFeature[]; + popular?: boolean; + tier?: string; +} + +interface FeatureComparisonTableProps { + plans: PlanWithFeatures[]; +} + +const FeatureComparisonTable: React.FC = ({ plans }) => { + const { t } = useTranslation(); + + return ( + + + {t('plan.featureComparison', 'Feature Comparison')} + + +
+ + + + + {plans.map((plan, index) => ( + + ))} + + + + {plans[0]?.features.map((_, featureIndex) => ( + + + {plans.map((plan, planIndex) => ( + + ))} + + ))} + +
+ {t('plan.feature.title', 'Feature')} + + {plan.name} + {plan.popular && ( + + {t('plan.popular', 'Popular')} + + )} +
+ {plans[0].features[featureIndex].name} + + {plan.features[featureIndex]?.included ? ( + + ✓ + + ) : ( + + − + + )} +
+
+
+ ); +}; + +export default FeatureComparisonTable; 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..696fa50e9 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { Button, Card, Badge, Text, 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} + + + {t('plan.from', 'From')} + + + £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} + + + + {t('plan.from', 'From')} + + + {/* Price */} + {isEnterprise && displaySeatPrice !== undefined ? ( + <> + + {displayCurrency}{displayPrice} + + + + {displayCurrency}{displaySeatPrice}/seat {t('plan.perMonth', '/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..3bdd06baf --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx @@ -0,0 +1,338 @@ +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, mapLicenseToTier } from '@app/services/licenseService'; +import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants'; +import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable'; + +interface PremiumSettingsData { + key?: string; + enabled?: boolean; +} + +interface StaticPlanSectionProps { + currentLicenseInfo?: LicenseInfo; +} + +const StaticPlanSection: React.FC = ({ currentLicenseInfo }) => { + const { t } = useTranslation(); + const [showLicenseKey, setShowLicenseKey] = useState(false); + const [showComparison, setShowComparison] = 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: '', + highlights: PLAN_HIGHLIGHTS.FREE, + features: PLAN_FEATURES.FREE, + maxUsers: 5, + }, + { + id: 'server', + name: 'Server', + price: 0, + currency: '', + period: '', + popular: false, + highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY, + features: PLAN_FEATURES.SERVER, + maxUsers: 'Unlimited users', + }, + { + id: 'enterprise', + name: t('plan.enterprise.name', 'Enterprise'), + price: 0, + currency: '', + period: '', + highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY, + features: PLAN_FEATURES.ENTERPRISE, + maxUsers: 'Custom', + }, + ]; + + const getCurrentPlan = () => { + const tier = mapLicenseToTier(currentLicenseInfo || null); + if (tier === 'enterprise') return staticPlans[2]; + if (tier === 'server') return staticPlans[1]; + return staticPlans[0]; // free + }; + + 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.id === currentPlan.id && ( + + {t('plan.current', 'Current Plan')} + + )} + {plan.popular && plan.id !== currentPlan.id && ( + + {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} + + ))} + + +
+ + + + + ))} +
+ + {/* Feature Comparison Toggle */} +
+ +
+ + {/* Feature Comparison Table */} + + + +
+ + + + {/* 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/constants/planConstants.ts b/frontend/src/proprietary/constants/planConstants.ts new file mode 100644 index 000000000..1865238df --- /dev/null +++ b/frontend/src/proprietary/constants/planConstants.ts @@ -0,0 +1,97 @@ +import { PlanFeature } from '@app/services/licenseService'; + +/** + * Shared plan feature definitions for Stirling PDF Self-Hosted + * Used by both dynamic (Stripe) and static (fallback) plan displays + */ + +export const PLAN_FEATURES = { + FREE: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Secure Login Support', included: true }, + { name: 'Community support', included: true }, + { name: 'Regular updates', included: true }, + { name: 'up to 5 users', included: true }, + { name: 'Unlimited users', included: false }, + { name: 'Google drive integration', included: false }, + { name: 'External Database', included: false }, + { name: 'Editing text in pdfs', included: false }, + { name: 'Users limited to seats', included: false }, + { name: 'SSO', included: false }, + { name: 'Auditing', included: false }, + { name: 'Usage tracking', included: false }, + { name: 'Prometheus Support', included: false }, + { name: 'Custom PDF metadata', included: false }, + ] as PlanFeature[], + + SERVER: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Secure Login Support', included: true }, + { name: 'Community support', included: true }, + { name: 'Regular updates', included: true }, + { name: 'Up to 5 users', included: false }, + { name: 'Unlimited users', included: true }, + { name: 'Google drive integration', included: true }, + { name: 'External Database', included: true }, + { name: 'Editing text in pdfs', included: true }, + { name: 'Users limited to seats', included: false }, + { name: 'SSO', included: false }, + { name: 'Auditing', included: false }, + { name: 'Usage tracking', included: false }, + { name: 'Prometheus Support', included: false }, + { name: 'Custom PDF metadata', included: false }, + ] as PlanFeature[], + + ENTERPRISE: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Secure Login Support', included: true }, + { name: 'Community support', included: true }, + { name: 'Regular updates', included: true }, + { name: 'up to 5 users', included: false }, + { name: 'Unlimited users', included: false }, + { name: 'Google drive integration', included: true }, + { name: 'External Database', included: true }, + { name: 'Editing text in pdfs', included: true }, + { name: 'Users limited to seats', included: true }, + { name: 'SSO', included: true }, + { name: 'Auditing', included: true }, + { name: 'Usage tracking', included: true }, + { name: 'Prometheus Support', included: true }, + { name: 'Custom PDF metadata', included: true }, + ] as PlanFeature[], +} as const; + +export const PLAN_HIGHLIGHTS = { + FREE: [ + 'Up to 5 users', + 'Self-hosted', + 'All basic features' + ], + SERVER_MONTHLY: [ + 'Self-hosted on your infrastructure', + 'Unlimited users', + 'Advanced integrations', + 'Cancel anytime' + ], + SERVER_YEARLY: [ + 'Self-hosted on your infrastructure', + 'Unlimited users', + 'Advanced integrations', + 'Save with annual billing' + ], + ENTERPRISE_MONTHLY: [ + 'Enterprise features (SSO, Auditing)', + 'Usage tracking & Prometheus', + 'Custom PDF metadata', + 'Per-seat licensing' + ], + ENTERPRISE_YEARLY: [ + 'Enterprise features (SSO, Auditing)', + 'Usage tracking & Prometheus', + 'Custom PDF metadata', + 'Save with annual billing' + ] +} as const; diff --git a/frontend/src/proprietary/contexts/CheckoutContext.tsx b/frontend/src/proprietary/contexts/CheckoutContext.tsx new file mode 100644 index 000000000..f8558a650 --- /dev/null +++ b/frontend/src/proprietary/contexts/CheckoutContext.tsx @@ -0,0 +1,350 @@ +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'; +import { isSupabaseConfigured } from '@app/services/supabaseClient'; + +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); + + // Check if Supabase is configured + if (!isSupabaseConfigured) { + throw new Error('Checkout is not available. Supabase is not configured.'); + } + + // 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..9414dcf5d --- /dev/null +++ b/frontend/src/proprietary/contexts/LicenseContext.tsx @@ -0,0 +1,74 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; +import licenseService, { LicenseInfo } from '@app/services/licenseService'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; + +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 { config } = useAppConfig(); + const [licenseInfo, setLicenseInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetchLicense = useCallback(async () => { + // Only fetch license info if user is an admin + if (!config?.isAdmin) { + console.debug('[LicenseContext] User is not an admin, skipping license fetch'); + setLoading(false); + return; + } + + 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); + } + }, [config?.isAdmin]); + + // Fetch license info when config changes (only if user is admin) + useEffect(() => { + if (config) { + refetchLicense(); + } + }, [config, 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..409dbfd74 --- /dev/null +++ b/frontend/src/proprietary/services/licenseService.ts @@ -0,0 +1,475 @@ +import apiClient from '@app/services/apiClient'; +import { supabase, isSupabaseConfigured } from '@app/services/supabaseClient'; +import { getCheckoutMode } from '@app/utils/protocolDetection'; +import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants'; + +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: readonly 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: readonly 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 { + // Check if Supabase is configured + if (!isSupabaseConfigured || !supabase) { + throw new Error('Supabase is not configured. Please use static plans instead.'); + } + + // 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: PLAN_FEATURES.SERVER, + highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY + }, + { + id: 'selfhosted:server:yearly', + lookupKey: 'selfhosted:server:yearly', + name: 'Server - Yearly', + price: getPriceInfo('selfhosted:server:yearly'), + currency: currencySymbol, + period: '/year', + popular: true, + features: PLAN_FEATURES.SERVER, + highlights: PLAN_HIGHLIGHTS.SERVER_YEARLY + }, + { + 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: PLAN_FEATURES.ENTERPRISE, + highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY + }, + { + 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: PLAN_FEATURES.ENTERPRISE, + highlights: PLAN_HIGHLIGHTS.ENTERPRISE_YEARLY + }, + ]; + + // 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: PLAN_FEATURES.FREE, + highlights: PLAN_HIGHLIGHTS.FREE + }; + + 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 { + // Check if Supabase is configured + if (!isSupabaseConfigured || !supabase) { + throw new Error('Supabase is not configured. Checkout is not available.'); + } + + // 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 { + // Check if Supabase is configured + if (!isSupabaseConfigured || !supabase) { + throw new Error('Supabase is not configured. Billing portal is not available.'); + } + + 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 { + // Check if Supabase is configured + if (!isSupabaseConfigured || !supabase) { + throw new Error('Supabase is not configured. License key lookup is not available.'); + } + + 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 { From 6c8d2c89feba0a49d18f935d358c119c221088d2 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:21:42 +0000 Subject: [PATCH 8/8] Add initial Windows signing infrastructure (#4945) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton Co-authored-by: James Brunton --- .github/workflows/tauri-build.yml | 306 ++++++++++++++++++++++++++++- WINDOWS_SIGNING.md | 258 ++++++++++++++++++++++++ frontend/src-tauri/tauri.conf.json | 5 + 3 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 WINDOWS_SIGNING.md diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 4c833bc16..09ef22a16 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -14,7 +14,7 @@ on: - macos - linux pull_request: - branches: [main, V2] + branches: [main, V2, V2-tauri-windows] paths: - 'frontend/src-tauri/**' - 'frontend/src/desktop/**' @@ -61,6 +61,9 @@ jobs: fail-fast: false matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }} runs-on: ${{ matrix.platform }} + env: + SM_API_KEY: ${{ secrets.SM_API_KEY }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} steps: - name: Harden Runner uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 @@ -174,6 +177,84 @@ jobs: working-directory: ./frontend run: npm install + # DigiCert KeyLocker Setup (Cloud HSM) + - name: Setup DigiCert KeyLocker + id: digicert-setup + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }} + uses: digicert/ssm-code-signing@v1.1.0 + env: + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} + SM_HOST: ${{ secrets.SM_HOST }} + + - name: Setup DigiCert KeyLocker Certificate + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }} + shell: pwsh + run: | + Write-Host "Setting up DigiCert KeyLocker environment..." + + # Decode client certificate + $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}") + $certPath = "D:\Certificate_pkcs12.p12" + [IO.File]::WriteAllBytes($certPath, $certBytes) + + # Set environment variables + echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV + echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV + echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV + echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV + echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV + + # Get PKCS11 config path from DigiCert action + $pkcs11Config = $env:PKCS11_CONFIG + if ($pkcs11Config) { + Write-Host "Found PKCS11_CONFIG: $pkcs11Config" + echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV + } else { + Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path" + $defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg" + if (Test-Path $defaultPath) { + Write-Host "Found config at default path: $defaultPath" + echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV + } else { + Write-Host "Warning: Could not find PKCS11 config file" + } + } + + # Traditional PFX Certificate Import (fallback if KeyLocker not configured) + - name: Import Windows Code Signing Certificate + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' }} + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + shell: powershell + run: | + if ($env:WINDOWS_CERTIFICATE) { + Write-Host "Importing Windows Code Signing Certificate..." + + # Decode base64 certificate and save to file + $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) + $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx" + [IO.File]::WriteAllBytes($certPath, $certBytes) + + # Import certificate to CurrentUser\My store + $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force) + + # Extract and set thumbprint as environment variable + $thumbprint = $cert.Thumbprint + Write-Host "Certificate imported with thumbprint: $thumbprint" + echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV + + # Clean up certificate file + Remove-Item $certPath + + Write-Host "Windows certificate import completed." + } else { + Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary" + } + - name: Import Apple Developer Certificate if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' env: @@ -229,13 +310,174 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }} - SIGN: 1 + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }} CI: true with: projectPath: ./frontend tauriScript: npx tauri args: ${{ matrix.args }} + # Sign with DigiCert KeyLocker (post-build) + - name: Sign Windows binaries with DigiCert KeyLocker + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }} + shell: pwsh + run: | + Write-Host "=== DigiCert KeyLocker Signing ===" + + # Test smctl connectivity first + Write-Host "Testing smctl connection..." + $healthCheck = & smctl healthcheck 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "[SUCCESS] Connected to DigiCert KeyLocker" + } else { + Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker" + Write-Host $healthCheck + exit 1 + } + Write-Host "" + + # Sync certificates to Windows certificate store + Write-Host "Syncing certificates to Windows certificate store..." + $syncOutput = & smctl windows certsync 2>&1 + Write-Host "Cert sync result: $syncOutput" + Write-Host "" + + # List available certificates and check if they have certificates attached + Write-Host "Checking for available certificates..." + $certList = & smctl keypair ls 2>&1 + Write-Host "Keypair list output:" + Write-Host $certList + Write-Host "" + + # Parse the output to check certificate status + $lines = $certList -split "`n" + $foundKeypair = $false + $hasCertificate = $false + + foreach ($line in $lines) { + if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") { + $foundKeypair = $true + Write-Host "[SUCCESS] Found keypair in list" + + # Check if this line has certificate info (not just empty spaces after alias) + $parts = $line -split "\s+" + if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") { + $hasCertificate = $true + Write-Host "[SUCCESS] Certificate is associated with keypair" + } + } + } + + if (-not $foundKeypair) { + Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}" + Write-Host "Available keypairs are listed above" + Write-Host "" + Write-Host "Please verify:" + Write-Host " 1. Keypair alias is correct in GitHub secret" + Write-Host " 2. API key has access to this keypair" + exit 1 + } + + if (-not $hasCertificate) { + Write-Host "[ERROR] No certificate associated with keypair" + Write-Host "This usually means:" + Write-Host " 1. Certificate not yet synced to KeyLocker (run sync manually)" + Write-Host " 2. Certificate is pending approval" + Write-Host " 3. Certificate needs to be attached to the keypair" + Write-Host "" + Write-Host "Try running in DigiCert ONE portal:" + Write-Host " smctl keypair sync" + exit 1 + } + + Write-Host "[SUCCESS] Certificate check passed" + Write-Host "" + + # Find only the files we need to sign (not build scripts) + $filesToSign = @() + + # Main application executable + $mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue + if ($mainExe) { $filesToSign += $mainExe } + + # MSI installer + $msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File + $filesToSign += $msiFiles + + if ($filesToSign.Count -eq 0) { + Write-Host "[ERROR] No files found to sign" + exit 1 + } + + Write-Host "Found $($filesToSign.Count) files to sign:" + foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" } + Write-Host "" + + $signedCount = 0 + foreach ($file in $filesToSign) { + Write-Host "Signing: $($file.Name)" + + # Get PKCS11 config file path (set by DigiCert action) + $pkcs11Config = $env:PKCS11_CONFIG + if (-not $pkcs11Config) { + Write-Host "[ERROR] PKCS11_CONFIG environment variable not set" + Write-Host "DigiCert KeyLocker action may not have run correctly" + exit 1 + } + + Write-Host "Using PKCS11 config: $pkcs11Config" + + # Try signing with certificate fingerprint first (if available) + $fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" + if ($fingerprint -and $fingerprint -ne "") { + Write-Host "Attempting to sign with certificate fingerprint..." + $output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1 + $exitCode = $LASTEXITCODE + } else { + Write-Host "No fingerprint provided, using keypair alias..." + # Use smctl to sign with keypair alias + $output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1 + $exitCode = $LASTEXITCODE + } + + Write-Host "Exit code: $exitCode" + Write-Host "Output: $output" + + # Check if output contains "FAILED" even with exit code 0 + if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") { + Write-Host "" + Write-Host "[ERROR] Signing failed for $($file.Name)" + Write-Host "[ERROR] smctl returned success but output indicates failure" + Write-Host "" + Write-Host "Possible issues:" + Write-Host " 1. Certificate not fully synced to KeyLocker (wait a few minutes)" + Write-Host " 2. Incorrect keypair alias" + Write-Host " 3. API key lacks signing permissions" + Write-Host "" + Write-Host "Please verify in DigiCert ONE portal:" + Write-Host " - Certificate status is 'Issued' (not Pending)" + Write-Host " - Keypair status is 'Online'" + Write-Host " - 'Can sign' is set to 'Yes'" + exit 1 + } + + if ($exitCode -ne 0) { + Write-Host "[ERROR] Failed to sign $($file.Name)" + Write-Host "Full error output:" + Write-Host $output + exit 1 + } + + $signedCount++ + Write-Host "[SUCCESS] Signed: $($file.Name)" + Write-Host "" + } + + Write-Host "=== Summary ===" + Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully" + - name: Verify notarization (macOS only) if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' run: | @@ -269,6 +511,66 @@ jobs: find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \; fi + - name: Verify Windows Code Signature + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + Write-Host "Verifying Windows code signatures..." + + $exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe" + $msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi" + + $allSigned = $true + $usingKeyLocker = "${{ env.SM_API_KEY }}" -ne "" + $usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne "" + + # Check EXE signature + if (Test-Path $exePath) { + $exeSig = Get-AuthenticodeSignature -FilePath $exePath + Write-Host "EXE Signature Status: $($exeSig.Status)" + Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)" + Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)" + + if ($exeSig.Status -ne "Valid") { + Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))" + if ($usingKeyLocker -or $usingPfx) { + Write-Host "[ERROR] Certificate was provided but signing failed" + $allSigned = $false + } else { + Write-Host "[INFO] Building unsigned binary (no certificate provided)" + } + } else { + Write-Host "[SUCCESS] EXE is properly signed" + } + } + + # Check MSI signature + if (Test-Path $msiPath) { + $msiSig = Get-AuthenticodeSignature -FilePath $msiPath + Write-Host "MSI Signature Status: $($msiSig.Status)" + Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)" + Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)" + + if ($msiSig.Status -ne "Valid") { + Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))" + if ($usingKeyLocker -or $usingPfx) { + Write-Host "[ERROR] Certificate was provided but signing failed" + $allSigned = $false + } else { + Write-Host "[INFO] Building unsigned binary (no certificate provided)" + } + } else { + Write-Host "[SUCCESS] MSI is properly signed" + } + } + + if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) { + Write-Host "[ERROR] Code signing verification failed" + exit 1 + } else { + Write-Host "[SUCCESS] Code signature verification completed" + } + - name: Upload artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/WINDOWS_SIGNING.md b/WINDOWS_SIGNING.md new file mode 100644 index 000000000..45528e13a --- /dev/null +++ b/WINDOWS_SIGNING.md @@ -0,0 +1,258 @@ +# Windows Code Signing Setup Guide + +This guide explains how to set up Windows code signing for Stirling-PDF desktop application builds. + +## Overview + +Windows code signing is essential for: +- Preventing Windows SmartScreen warnings +- Building trust with users +- Enabling Microsoft Store distribution +- Professional application distribution + +## Certificate Types + +### OV Certificate (Organization Validated) +- More affordable option +- Requires business verification +- May trigger SmartScreen warnings initially until reputation builds +- Suitable for most independent software vendors + +### EV Certificate (Extended Validation) +- Premium option with immediate SmartScreen reputation +- Requires hardware security module (HSM) or cloud-based signing +- Higher cost but provides immediate trust +- Required since June 2023 for new certificates + +## Obtaining a Certificate + +### Certificate Authorities +Popular certificate authorities for Windows code signing: +- DigiCert +- Sectigo (formerly Comodo) +- GlobalSign +- SSL.com + +### Certificate Format +You'll receive a certificate in one of these formats: +- `.pfx` or `.p12` (preferred - contains both certificate and private key) +- `.cer` + private key (needs conversion to .pfx) + +### Converting to PFX (if needed) +If you have separate certificate and private key files: + +```bash +openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certificate.cer +``` + +## Setting Up GitHub Secrets + +### Required Secrets + +Navigate to your GitHub repository → Settings → Secrets and variables → Actions + +Add the following secrets: + +#### 1. `WINDOWS_CERTIFICATE` +- **Description**: Base64-encoded .pfx certificate file +- **How to create**: + +**On macOS/Linux:** +```bash +base64 -i certificate.pfx | pbcopy # Copies to clipboard +``` + +**On Windows (PowerShell):** +```powershell +[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard +``` + +Paste the entire base64 string into the GitHub secret. + +#### 2. `WINDOWS_CERTIFICATE_PASSWORD` +- **Description**: Password for the .pfx certificate +- **Value**: The password you set when creating/exporting the .pfx file + +### Optional Secrets for Tauri Updater + +If you're using Tauri's built-in updater feature: + +#### `TAURI_SIGNING_PRIVATE_KEY` +- Generated using Tauri CLI: `npm run tauri signer generate` +- Used for update package verification + +#### `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` +- Password for the Tauri signing key + +## Configuration Files + +### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json) + +The Windows signing configuration is already set up: + +```json +"windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "http://timestamp.digicert.com" +} +``` + +**Configuration Options:** +- `certificateThumbprint`: Automatically extracted from imported certificate (leave as `null`) +- `digestAlgorithm`: Hashing algorithm - `sha256` is recommended +- `timestampUrl`: Timestamp server to prove signing time (survives certificate expiration) + +**Alternative Timestamp Servers:** +- DigiCert: `http://timestamp.digicert.com` +- Sectigo: `http://timestamp.sectigo.com` +- GlobalSign: `http://timestamp.globalsign.com` + +### 2. GitHub Workflow (.github/workflows/tauri-build.yml) + +The workflow includes three Windows signing steps: + +1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store +2. **Build Tauri App**: Builds and signs the application using the imported certificate +3. **Verify Signature**: Validates that both .exe and .msi files are properly signed + +## Testing the Setup + +### 1. Local Testing (Windows Only) + +Before pushing to GitHub, test locally: + +```powershell +# Set environment variables +$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) +$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password" + +# Build the application +cd frontend +npm run tauri build + +# Verify the signature +Get-AuthenticodeSignature "./src-tauri/target/release/bundle/msi/Stirling-PDF_*.msi" +``` + +### 2. GitHub Actions Testing + +1. Push your changes to a branch +2. Manually trigger the workflow: + - Go to Actions → Build Tauri Applications + - Click "Run workflow" + - Select "windows" platform +3. Check the build logs for: + - ✅ Certificate import success + - ✅ Build completion + - ✅ Signature verification + +### 3. Verifying Signed Binaries + +After downloading the built artifacts: + +**Windows (PowerShell):** +```powershell +Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.exe" +Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.msi" +``` + +Look for: +- Status: `Valid` +- Signer: Your organization name +- Timestamp: Recent date/time + +**Windows (GUI):** +1. Right-click the .exe or .msi file +2. Select "Properties" +3. Go to "Digital Signatures" tab +4. Verify signature details + +## Troubleshooting + +### "HashMismatch" Status +- Certificate doesn't match the binary +- Possible file corruption during download +- Re-download and verify + +### "NotSigned" Status +- Certificate wasn't imported correctly +- Check GitHub secrets are set correctly +- Verify base64 encoding is complete (no truncation) + +### "UnknownError" Status +- Timestamp server unreachable +- Try alternative timestamp URL in tauri.conf.json +- Check network connectivity in GitHub Actions + +### SmartScreen Still Shows Warnings +- Normal for OV certificates initially +- Reputation builds over time with user downloads +- Consider EV certificate for immediate reputation + +### Certificate Not Found During Build +- Verify `WINDOWS_CERTIFICATE` secret is set +- Check base64 encoding is correct (no extra whitespace) +- Ensure password is correct + +## Security Best Practices + +1. **Never commit certificates to version control** + - Keep .pfx files secure and backed up + - Use GitHub secrets for CI/CD + +2. **Rotate certificates before expiration** + - Set calendar reminders + - Update GitHub secrets with new certificate + +3. **Use strong passwords** + - Certificate password should be complex + - Store securely (password manager) + +4. **Monitor certificate usage** + - Review GitHub Actions logs + - Set up notifications for failed builds + +5. **Limit access to secrets** + - Only repository admins should access secrets + - Audit secret access regularly + +## Certificate Lifecycle + +### Before Expiration +1. Obtain new certificate from CA (typically annual renewal) +2. Convert to .pfx format if needed +3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate +4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed +5. Test build to verify new certificate works + +### Expired Certificates +- Signed binaries remain valid (timestamp proves signing time) +- New builds will fail until certificate is renewed +- Users can still install previously signed versions + +## Cost Considerations + +### Certificate Costs (Annual, as of 2024) +- **OV Certificate**: $100-400/year +- **EV Certificate**: $400-1000/year + +### Choosing the Right Certificate +- **Open source / early stage**: Start with OV +- **Commercial / enterprise**: Consider EV for better trust +- **Microsoft Store**: EV certificate required + +## Additional Resources + +- [Tauri Windows Signing Documentation](https://v2.tauri.app/distribute/sign/windows/) +- [Microsoft Code Signing Overview](https://docs.microsoft.com/windows/win32/seccrypto/cryptography-tools) +- [DigiCert Code Signing Guide](https://www.digicert.com/signing/code-signing-certificates) +- [Windows SmartScreen FAQ](https://support.microsoft.com/windows/smartscreen-faq) + +## Support + +If you encounter issues with Windows code signing: +1. Check GitHub Actions logs for detailed error messages +2. Verify all secrets are set correctly +3. Test certificate locally first (Windows environment required) +4. Open an issue in the repository with relevant logs (remove sensitive data) diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index a6dffc881..a7c3355d8 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -51,6 +51,11 @@ "desktopTemplate": "stirling-pdf.desktop" } }, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "http://timestamp.digicert.com" + }, "macOS": { "minimumSystemVersion": "10.15", "signingIdentity": null,