From e474cc76ad9f7e5df7620127f25abf23a4c84986 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:13:20 +0000 Subject: [PATCH 01/24] Improved static upgrade flow (#5214) image image image image image image image --- .../public/locales/en-GB/translation.toml | 22 ++ .../configSections/AdminPlanSection.tsx | 240 +------------ .../plan/AvailablePlansSection.tsx | 23 +- .../configSections/plan/LicenseKeySection.tsx | 273 ++++++++++++++ .../config/configSections/plan/PlanCard.tsx | 5 +- .../plan/StaticCheckoutModal.tsx | 338 ++++++++++++++++++ .../configSections/plan/StaticPlanSection.tsx | 322 +++++++++-------- .../constants/staticStripeLinks.ts | 56 +++ .../src/proprietary/utils/planTierUtils.ts | 40 +++ 9 files changed, 917 insertions(+), 402 deletions(-) create mode 100644 frontend/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/plan/StaticCheckoutModal.tsx create mode 100644 frontend/src/proprietary/constants/staticStripeLinks.ts create mode 100644 frontend/src/proprietary/utils/planTierUtils.ts diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 9d9278922..9be190247 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5569,6 +5569,28 @@ contactSales = "Contact Sales" contactToUpgrade = "Contact us to upgrade or customize your plan" maxUsers = "Max Users" upTo = "Up to" +getLicense = "Get Server License" +upgradeToEnterprise = "Upgrade to Enterprise" +selectPeriod = "Select Billing Period" +monthlyBilling = "Monthly Billing" +yearlyBilling = "Yearly Billing" +checkoutOpened = "Checkout Opened" +checkoutInstructions = "Complete your purchase in the Stripe tab. After payment, return here and refresh the page to activate your license. You will also receive an email with your license key." +activateLicense = "Activate Your License" + +[plan.static.licenseActivation] +checkoutOpened = "Checkout Opened in New Tab" +instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key." +enterKey = "Enter your license key below to activate your plan:" +keyDescription = "Paste the license key from your email" +activate = "Activate License" +doLater = "I'll do this later" +success = "License Activated!" +successMessage = "Your license has been successfully activated. You can now close this window." + +[plan.static.billingPortal] +title = "Email Verification Required" +message = "You will need to verify your email address in the Stripe billing portal. Check your email for a login link." [plan.period] month = "month" diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index 2c86c9ced..0b47cf148 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react'; -import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper, SegmentedControl, FileButton } from '@mantine/core'; +import { Divider, Loader, Alert } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePlans } from '@app/hooks/usePlans'; import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService'; @@ -7,30 +7,25 @@ import { useCheckout } from '@app/contexts/CheckoutContext'; import { useLicense } from '@app/contexts/LicenseContext'; import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection'; import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection'; +import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection'; import { alert } from '@app/components/toast'; -import LocalIcon from '@app/components/shared/LocalIcon'; import { InfoBanner } from '@app/components/shared/InfoBanner'; import { useLicenseAlert } from '@app/hooks/useLicenseAlert'; -import { isSupabaseConfigured } from '@app/services/supabaseClient'; import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@core/components/shared/config/LoginRequiredBanner'; +import { isSupabaseConfigured } from '@app/services/supabaseClient'; const AdminPlanSection: React.FC = () => { const { t, i18n } = useTranslation(); const { loginEnabled, validateLoginEnabled } = useLoginRequired(); const { openCheckout } = useCheckout(); - const { licenseInfo, refetchLicense } = useLicense(); + const { licenseInfo } = useLicense(); const [currency, setCurrency] = useState(() => { // Initialize with auto-detected currency on first render return getPreferredCurrency(i18n.language); }); const [useStaticVersion, setUseStaticVersion] = useState(false); - const [showLicenseKey, setShowLicenseKey] = useState(false); - const [licenseKeyInput, setLicenseKeyInput] = useState(''); - const [savingLicense, setSavingLicense] = useState(false); - const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text'); - const [licenseFile, setLicenseFile] = useState(null); const { plans, loading, error, refetch } = usePlans(currency); const licenseAlert = useLicenseAlert(); @@ -43,69 +38,6 @@ const AdminPlanSection: React.FC = () => { } }, [error]); - const handleSaveLicense = async () => { - // Block save if login is disabled - if (!validateLoginEnabled()) { - return; - } - - try { - setSavingLicense(true); - - let response; - - if (inputMethod === 'file' && licenseFile) { - // Upload file - response = await licenseService.saveLicenseFile(licenseFile); - } else if (inputMethod === 'text' && licenseKeyInput.trim()) { - // Save key string (allow empty string to clear/remove license) - response = await licenseService.saveLicenseKey(licenseKeyInput.trim()); - } else { - alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: t('admin.settings.premium.noInput', 'Please provide a license key or file'), - }); - return; - } - - if (response.success) { - // Refresh license context to update all components - await refetchLicense(); - - const successMessage = inputMethod === 'file' - ? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully') - : t('admin.settings.premium.key.successMessage', 'License key activated successfully'); - - alert({ - alertType: 'success', - title: t('success', 'Success'), - body: successMessage, - }); - - // Clear inputs - setLicenseKeyInput(''); - setLicenseFile(null); - setInputMethod('text'); // Reset to default - } else { - alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: response.error || t('admin.settings.saveError', 'Failed to save license'), - }); - } - } catch (error) { - console.error('Failed to save license:', error); - alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: t('admin.settings.saveError', 'Failed to save license'), - }); - } finally { - setSavingLicense(false); - } - }; - const currencyOptions = [ { value: 'gbp', label: 'British pound (GBP, £)' }, { value: 'usd', label: 'US dollar (USD, $)' }, @@ -280,169 +212,7 @@ const AdminPlanSection: React.FC = () => { {/* 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.')} - - - - )} - - {/* Show current license source */} - {licenseInfo?.licenseKey && ( - } - > - - - {t('admin.settings.premium.currentLicense.title', 'Active License')} - - - {licenseInfo.licenseKey.startsWith('file:') - ? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', { - path: licenseInfo.licenseKey.substring(5) - }) - : t('admin.settings.premium.currentLicense.key', 'Source: License key')} - - - {t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', { - type: licenseInfo.licenseType - })} - - - - )} - - {/* Input method selector */} - { - setInputMethod(value as 'text' | 'file'); - // Clear opposite input when switching - if (value === 'text') setLicenseFile(null); - if (value === 'file') setLicenseKeyInput(''); - }} - data={[ - { - label: t('admin.settings.premium.inputMethod.text', 'License Key'), - value: 'text' - }, - { - label: t('admin.settings.premium.inputMethod.file', 'Certificate File'), - value: 'file' - } - ]} - disabled={!loginEnabled || savingLicense} - /> - - {/* Input area */} - - - {inputMethod === 'text' ? ( - /* Existing text input */ - setLicenseKeyInput(e.target.value)} - placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} - type="password" - disabled={!loginEnabled || savingLicense} - /> - ) : ( - /* File upload */ -
- - {t('admin.settings.premium.file.label', 'License Certificate File')} - - - {t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')} - - - {(props) => ( - - )} - - {licenseFile && ( - - {t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', { - filename: licenseFile.name, - size: (licenseFile.size / 1024).toFixed(2) + ' KB' - })} - - )} -
- )} - - - - -
-
-
-
-
+ ); }; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx index a3b8800d1..9aed2ff7d 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx @@ -5,6 +5,7 @@ import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard'; import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade } from '@app/utils/planTierUtils'; interface AvailablePlansSectionProps { plans: PlanTier[]; @@ -43,28 +44,12 @@ const AvailablePlansSection: React.FC = ({ // 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; + return checkIsCurrentTier(currentTier, tierGroup.tier); }; // 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 checkIsDowngrade(currentTier, tierGroup.tier); }; return ( @@ -103,7 +88,7 @@ const AvailablePlansSection: React.FC = ({ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', - marginBottom: '0.5rem', + marginBottom: '0.1rem', }} > {groupedPlans.map((group) => ( diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx new file mode 100644 index 000000000..e5e90d048 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx @@ -0,0 +1,273 @@ +import React, { useState } from 'react'; +import { Button, Collapse, Alert, TextInput, Paper, Stack, Group, Text, SegmentedControl, FileButton } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { alert } from '@app/components/toast'; +import { LicenseInfo } from '@app/services/licenseService'; +import licenseService from '@app/services/licenseService'; +import { useLicense } from '@app/contexts/LicenseContext'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; + +interface LicenseKeySectionProps { + currentLicenseInfo?: LicenseInfo; +} + +const LicenseKeySection: React.FC = ({ currentLicenseInfo }) => { + const { t } = useTranslation(); + const { refetchLicense } = useLicense(); + const { loginEnabled, validateLoginEnabled } = useLoginRequired(); + const [showLicenseKey, setShowLicenseKey] = useState(false); + const [licenseKeyInput, setLicenseKeyInput] = useState(''); + const [savingLicense, setSavingLicense] = useState(false); + const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text'); + const [licenseFile, setLicenseFile] = useState(null); + + const handleSaveLicense = async () => { + // Block save if login is disabled + if (!validateLoginEnabled()) { + return; + } + + try { + setSavingLicense(true); + + let response; + + if (inputMethod === 'file' && licenseFile) { + // Upload file + response = await licenseService.saveLicenseFile(licenseFile); + } else if (inputMethod === 'text' && licenseKeyInput.trim()) { + // Save key string + response = await licenseService.saveLicenseKey(licenseKeyInput.trim()); + } else { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.premium.noInput', 'Please provide a license key or file'), + }); + return; + } + + if (response.success) { + // Refresh license context to update all components + await refetchLicense(); + + const successMessage = + inputMethod === 'file' + ? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully') + : t('admin.settings.premium.key.successMessage', 'License key activated successfully'); + + alert({ + alertType: 'success', + title: t('success', 'Success'), + body: successMessage, + }); + + // Clear inputs + setLicenseKeyInput(''); + setLicenseFile(null); + setInputMethod('text'); // Reset to default + } else { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: response.error || t('admin.settings.saveError', 'Failed to save license'), + }); + } + } catch (error) { + console.error('Failed to save license:', error); + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.saveError', 'Failed to save license'), + }); + } finally { + setSavingLicense(false); + } + }; + + return ( +
+ + + + + }> + + {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 */} + {currentLicenseInfo?.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.' + )} + + + + )} + + {/* Show current license source */} + {currentLicenseInfo?.licenseKey && ( + } + > + + + {t('admin.settings.premium.currentLicense.title', 'Active License')} + + + {currentLicenseInfo.licenseKey.startsWith('file:') + ? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', { + path: currentLicenseInfo.licenseKey.substring(5), + }) + : t('admin.settings.premium.currentLicense.key', 'Source: License key')} + + + {t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', { + type: currentLicenseInfo.licenseType, + })} + + + + )} + + {/* Input method selector */} + { + setInputMethod(value as 'text' | 'file'); + // Clear opposite input when switching + if (value === 'text') setLicenseFile(null); + if (value === 'file') setLicenseKeyInput(''); + }} + data={[ + { + label: t('admin.settings.premium.inputMethod.text', 'License Key'), + value: 'text', + }, + { + label: t('admin.settings.premium.inputMethod.file', 'Certificate File'), + value: 'file', + }, + ]} + disabled={!loginEnabled || savingLicense} + /> + + {/* Input area */} + + + {inputMethod === 'text' ? ( + /* Text input */ + setLicenseKeyInput(e.target.value)} + placeholder={currentLicenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} + type="password" + disabled={!loginEnabled || savingLicense} + /> + ) : ( + /* File upload */ +
+ + {t('admin.settings.premium.file.label', 'License Certificate File')} + + + {t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')} + + + {(props) => ( + + )} + + {licenseFile && ( + + {t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', { + filename: licenseFile.name, + size: (licenseFile.size / 1024).toFixed(2) + ' KB', + })} + + )} +
+ )} + + + + +
+
+
+
+
+ ); +}; + +export default LicenseKeySection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx index ab56fdad3..b2dce9c65 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx @@ -6,6 +6,7 @@ import { PricingBadge } from '@app/components/shared/stripeCheckout/components/P import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay'; import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils'; import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles'; +import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from '@app/utils/planTierUtils'; interface PlanCardProps { planGroup: PlanTierGroup; @@ -83,7 +84,7 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra const isEnterprise = planGroup.tier === 'enterprise'; // Block enterprise for free tier users (must have server first) - const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free'; + const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(currentTier, planGroup.tier); // Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing( @@ -174,7 +175,7 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra withArrow > + + + + )} + + {licenseActivated && ( + + + + )} + + ); + + default: + return null; + } + }; + + const canGoBack = stageHistory.length > 0 && stage !== 'license-activation'; + + return ( + + {canGoBack && ( + + + + )} + + {getModalTitle()} + + + } + size={isMobile ? '100%' : 600} + centered + radius="lg" + withCloseButton={true} + closeOnEscape={true} + closeOnClickOutside={false} + fullScreen={isMobile} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + {renderContent()} + + ); +}; + +export default StaticCheckoutModal; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx index ba937263a..2847ef94e 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx @@ -1,20 +1,16 @@ -import React, { useState, useEffect } from 'react'; -import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core'; +import React, { useState } from 'react'; +import { Card, Text, Stack, Button, Collapse, Divider, Tooltip } 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; -} +import StaticCheckoutModal from '@app/components/shared/config/configSections/plan/StaticCheckoutModal'; +import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection'; +import { STATIC_STRIPE_LINKS } from '@app/constants/staticStripeLinks'; +import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge'; +import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles'; +import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade, isEnterpriseBlockedForFree } from '@app/utils/planTierUtils'; interface StaticPlanSectionProps { currentLicenseInfo?: LicenseInfo; @@ -22,38 +18,45 @@ interface StaticPlanSectionProps { 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', - }); + // Static checkout modal state + const [checkoutModalOpened, setCheckoutModalOpened] = useState(false); + const [selectedPlan, setSelectedPlan] = useState<'server' | 'enterprise'>('server'); + const [isUpgrade, setIsUpgrade] = useState(false); - useEffect(() => { - fetchPremiumSettings(); - }, []); - - const handleSaveLicense = async () => { - try { - await savePremiumSettings(); - showRestartModal(); - } catch (_error) { + const handleOpenCheckout = (plan: 'server' | 'enterprise', upgrade: boolean) => { + // Prevent Free → Enterprise (must have Server first) + const currentTier = mapLicenseToTier(currentLicenseInfo || null); + if (currentTier === 'free' && plan === 'enterprise') { alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: t('admin.settings.saveError', 'Failed to save settings'), + alertType: 'warning', + title: t('plan.enterprise.requiresServer', 'Server Plan Required'), + body: t( + 'plan.enterprise.requiresServerMessage', + 'Please upgrade to the Server plan first before upgrading to Enterprise.' + ), }); + return; } + + setSelectedPlan(plan); + setIsUpgrade(upgrade); + setCheckoutModalOpened(true); + }; + + const handleManageBilling = () => { + // Show warning about email verification + alert({ + alertType: 'warning', + title: t('plan.static.billingPortal.title', 'Email Verification Required'), + body: t( + 'plan.static.billingPortal.message', + 'You will need to verify your email address in the Stripe billing portal. Check your email for a login link.' + ), + }); + + window.open(STATIC_STRIPE_LINKS.billingPortal, '_blank'); }; const staticPlans = [ @@ -122,7 +125,7 @@ const StaticPlanSection: React.FC = ({ currentLicenseInf display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', - paddingBottom: '1rem', + paddingBottom: '0.1rem', }} > {staticPlans.map((plan) => ( @@ -131,53 +134,27 @@ const StaticPlanSection: React.FC = ({ currentLicenseInf padding="lg" radius="md" withBorder - style={{ - position: 'relative', - display: 'flex', - flexDirection: 'column', - borderColor: plan.id === currentPlan.id ? 'var(--mantine-color-green-6)' : undefined, - borderWidth: plan.id === currentPlan.id ? '2px' : undefined, - }} + style={getBaseCardStyle(plan.id === currentPlan.id)} + className="plan-card" > {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 @@ -195,18 +172,123 @@ const StaticPlanSection: React.FC = ({ currentLicenseInf
- + ); } - > - {plan.id === currentPlan.id - ? t('plan.current', 'Current Plan') - : t('plan.contact', 'Contact Us')} - + + // Server Plan + if (plan.id === 'server') { + if (currentTier === 'free') { + return ( + + ); + } + if (isCurrent) { + return ( + + ); + } + if (isDowngradePlan) { + return ( + + ); + } + } + + // Enterprise Plan + if (plan.id === 'enterprise') { + if (isEnterpriseBlockedForFree(currentTier, plan.id)) { + return ( + + + + ); + } + if (currentTier === 'server') { + // TODO: Re-enable checkout flow when account syncing is ready + // return ( + // + // ); + return ( + + ); + } + if (isCurrent) { + return ( + + ); + } + } + + return null; + })()} ))} @@ -230,66 +312,14 @@ const StaticPlanSection: React.FC = ({ currentLicenseInf {/* 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 */} - setCheckoutModalOpened(false)} + planName={selectedPlan} + isUpgrade={isUpgrade} />
); diff --git a/frontend/src/proprietary/constants/staticStripeLinks.ts b/frontend/src/proprietary/constants/staticStripeLinks.ts new file mode 100644 index 000000000..3fb45b28c --- /dev/null +++ b/frontend/src/proprietary/constants/staticStripeLinks.ts @@ -0,0 +1,56 @@ +/** + * Static Stripe payment links for offline/self-hosted environments + * + * These links are used when Supabase is not configured, allowing users to + * purchase licenses directly through Stripe hosted checkout pages. + * + * NOTE: These are test environment URLs. Replace with production URLs before release. + */ + +export interface StaticStripeLinks { + server: { + monthly: string; + yearly: string; + }; + enterprise: { + monthly: string; + yearly: string; + }; + billingPortal: string; +} +// PRODCUTION LINKS FOR LIVE SERVER +export const STATIC_STRIPE_LINKS: StaticStripeLinks = { + server: { + monthly: 'https://buy.stripe.com/fZu4gB8Nv6ysfAj0ts8Zq03', + yearly: 'https://buy.stripe.com/9B68wR6Fn0a40Fpcca8Zq02', + }, + enterprise: { + monthly: '', + yearly: '', + }, + billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00', +}; + +// LINKS FOR TEST SERVER: +// export const STATIC_STRIPE_LINKS: StaticStripeLinks = { +// server: { +// monthly: 'https://buy.stripe.com/test_8x27sD4YL9Ut0Fr3Cp83C02', +// yearly: 'https://buy.stripe.com/test_4gMdR11Mz4A9ag17SF83C03', +// }, +// enterprise: { +// monthly: 'https://buy.stripe.com/test_8x2cMX9f18Qp9bX0qd83C04', +// yearly: 'https://buy.stripe.com/test_6oU00b2QD2s173P6OB83C05', +// }, +// billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00', +// }; + +/** + * Builds a Stripe URL with a prefilled email parameter + * @param baseUrl - The base Stripe checkout URL + * @param email - The email address to prefill + * @returns The complete URL with encoded email parameter + */ +export function buildStripeUrlWithEmail(baseUrl: string, email: string): string { + const encodedEmail = encodeURIComponent(email); + return `${baseUrl}?locked_prefilled_email=${encodedEmail}`; +} diff --git a/frontend/src/proprietary/utils/planTierUtils.ts b/frontend/src/proprietary/utils/planTierUtils.ts new file mode 100644 index 000000000..e4fa12f62 --- /dev/null +++ b/frontend/src/proprietary/utils/planTierUtils.ts @@ -0,0 +1,40 @@ +/** + * Shared utilities for plan tier comparisons and button logic + */ + +export type PlanTier = 'free' | 'server' | 'enterprise'; + +const TIER_HIERARCHY: Record = { + 'free': 1, + 'server': 2, + 'enterprise': 3, +}; + +/** + * Get numeric level for a tier + */ +export function getTierLevel(tier: PlanTier | string | null | undefined): number { + if (!tier) return 1; + return TIER_HIERARCHY[tier as PlanTier] || 1; +} + +/** + * Check if target tier is the current tier + */ +export function isCurrentTier(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean { + return getTierLevel(currentTier) === getTierLevel(targetTier); +} + +/** + * Check if target tier is a downgrade from current tier + */ +export function isDowngrade(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean { + return getTierLevel(currentTier) > getTierLevel(targetTier); +} + +/** + * Check if enterprise is blocked for free tier users + */ +export function isEnterpriseBlockedForFree(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean { + return currentTier === 'free' && targetTier === 'enterprise'; +} From e26035c3b3ea966cf1bfbe16bd8193c1c4a3e15e Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 11 Dec 2025 12:13:54 +0100 Subject: [PATCH 02/24] build(versioning): synchronize app version across Tauri and simulation configs (#5120) # Description of Changes - **What was changed** - Added `groovy.json.JsonOutput` and `groovy.json.JsonSlurper` imports to `build.gradle`. - Introduced a reusable `writeIfChanged(File targetFile, String newContent)` helper to avoid unnecessary file writes when content is unchanged. - Added `updateTauriConfigVersion(String version)` to: - Parse `frontend/src-tauri/tauri.conf.json`. - Set the `version` field from `project.version`. - Re-write the file as pretty-printed JSON (with a trailing line separator) only if content actually changed. - Added `updateSimulationVersion(File fileToUpdate, String version)` to: - Locate the `appVersion: ''` assignment via regex in simulation files. - Replace the existing version with `project.version`. - Fail the build with a clear `GradleException` if `appVersion` cannot be found. - Registered a new Gradle task `syncAppVersion` (group: `versioning`) which: - Reads `project.version` as the canonical app version. - Updates `frontend/src-tauri/tauri.conf.json`. - Updates `frontend/src/core/testing/serverExperienceSimulations.ts`. - Updates `frontend/src/proprietary/testing/serverExperienceSimulations.ts`. - Updated the main `build` task so it now depends on `syncAppVersion` in addition to `:stirling-pdf:bootJar` and `buildRestartHelper`. - **Why the change was made** - To ensure the desktop Tauri configuration and server experience simulation configs consistently use the same application version as defined in `project.version`. - To remove manual version bumps in multiple files and eliminate the risk of version mismatches between backend, desktop app, and simulation/testing tooling. - To minimize noise in commits and CI by only touching versioned files when their content actually changes (using `writeIfChanged`). --- ## 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. --- .github/workflows/build.yml | 17 +++++++++++-- build.gradle | 49 ++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a049bb90..a5df3e9ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -262,7 +262,13 @@ jobs: strategy: fail-fast: false matrix: - docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"] + include: + - docker-rev: docker/embedded/Dockerfile + artifact-suffix: Dockerfile + - docker-rev: docker/embedded/Dockerfile.ultra-lite + artifact-suffix: Dockerfile.ultra-lite + - docker-rev: docker/embedded/Dockerfile.fat + artifact-suffix: Dockerfile.fat steps: - name: Harden Runner uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 @@ -272,6 +278,13 @@ jobs: - name: Checkout Repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Free disk space on runner + run: | + echo "Disk space before cleanup:" && df -h + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost + docker system prune -af || true + echo "Disk space after cleanup:" && df -h + - name: Set up JDK 17 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 with: @@ -313,7 +326,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: reports-docker-${{ matrix.docker-rev }} + name: reports-docker-${{ matrix.artifact-suffix }} path: | build/reports/tests/ build/test-results/ diff --git a/build.gradle b/build.gradle index 64908ee4a..2941c2eb5 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,8 @@ plugins { } import com.github.jk1.license.render.* +import groovy.json.JsonOutput +import groovy.json.JsonSlurper ext { springBootVersion = "3.5.6" @@ -65,6 +67,51 @@ allprojects { } } +def writeIfChanged(File targetFile, String newContent) { + if (targetFile.getText('UTF-8') != newContent) { + targetFile.write(newContent, 'UTF-8') + } +} + +def updateTauriConfigVersion(String version) { + File tauriConfig = file('frontend/src-tauri/tauri.conf.json') + def parsed = new JsonSlurper().parse(tauriConfig) + parsed.version = version + + def formatted = JsonOutput.prettyPrint(JsonOutput.toJson(parsed)) + System.lineSeparator() + writeIfChanged(tauriConfig, formatted) +} + +def updateSimulationVersion(File fileToUpdate, String version) { + def content = fileToUpdate.getText('UTF-8') + def matcher = content =~ /(appVersion:\s*')([^']*)(')/ + + if (!matcher.find()) { + throw new GradleException("Could not locate appVersion in ${fileToUpdate} for synchronization") + } + + def updatedContent = matcher.replaceFirst("${matcher.group(1)}${version}${matcher.group(3)}") + writeIfChanged(fileToUpdate, updatedContent) +} + +tasks.register('syncAppVersion') { + group = 'versioning' + description = 'Synchronizes app version across desktop and simulation configs.' + + doLast { + def appVersion = project.version.toString() + println "Synchronizing application version to ${appVersion}" + updateTauriConfigVersion(appVersion) + + [ + 'frontend/src/core/testing/serverExperienceSimulations.ts', + 'frontend/src/proprietary/testing/serverExperienceSimulations.ts' + ].each { path -> + updateSimulationVersion(file(path), appVersion) + } + } +} + tasks.register('writeVersion', WriteProperties) { destinationFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties') println "Writing version.properties to ${destinationFile.get().asFile.path}" @@ -314,7 +361,7 @@ tasks.named('bootRun') { tasks.named('build') { group = 'build' description = 'Delegates to :stirling-pdf:bootJar' - dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper' + dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper', 'syncAppVersion' doFirst { println "Delegating to :stirling-pdf:bootJar" From 6565a6ce186b96fcf45245ece890e86aa5360cbd Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:19:55 +0000 Subject: [PATCH 03/24] Bug/v2/improved cache busting (#5107) # 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. --- .../software/SPDF/config/WebMvcConfig.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 0703708f5..8eac8fa80 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 @@ -1,10 +1,14 @@ package stirling.software.SPDF.config; +import java.util.concurrent.TimeUnit; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import lombok.RequiredArgsConstructor; @@ -25,6 +29,20 @@ public class WebMvcConfig implements WebMvcConfigurer { registry.addInterceptor(endpointInterceptor); } + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // Cache hashed assets (JS/CSS with content hashes) for 1 year + // These files have names like index-ChAS4tCC.js that change when content changes + registry.addResourceHandler("/assets/**") + .addResourceLocations("classpath:/static/assets/") + .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()); + + // Don't cache index.html - it needs to be fresh to reference latest hashed assets + registry.addResourceHandler("/index.html") + .addResourceLocations("classpath:/static/") + .setCacheControl(CacheControl.noCache().mustRevalidate()); + } + @Override public void addCorsMappings(CorsRegistry registry) { // Check if running in Tauri mode From ae723443171b2da7cccae5a37ae167f2762d56ec Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:23:20 +0000 Subject: [PATCH 04/24] Offline pdfium (#5213) # 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) ### 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. --- frontend/package-lock.json | 33 +++++++++++++++++++ frontend/package.json | 1 + .../core/components/viewer/LocalEmbedPDF.tsx | 7 ++-- frontend/vite.config.ts | 10 ++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8d90d9658..f788474bb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -105,6 +105,7 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", + "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" } @@ -11093,6 +11094,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -14503,6 +14517,25 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-static-copy": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz", + "integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.3", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, "node_modules/vite-tsconfig-paths": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5489f6b46..914b7bb1f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -152,6 +152,7 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", + "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" }, diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 3e5fa5f1c..0b79604fd 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -46,6 +46,7 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge'; import { isPdfFile } from '@app/utils/fileUtils'; import { useTranslation } from 'react-i18next'; import { LinkLayer } from '@app/components/viewer/LinkLayer'; +import { absoluteWithBasePath } from '@app/constants/app'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -167,8 +168,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur ]; }, [pdfUrl]); - // Initialize the engine with the React hook - const { engine, isLoading, error } = usePdfiumEngine(); + // Initialize the engine with the React hook - use local WASM for offline support + const { engine, isLoading, error } = usePdfiumEngine({ + wasmUrl: absoluteWithBasePath('/pdfium/pdfium.wasm'), + }); // Early return if no file or URL provided diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0eb35e94c..f8d52908d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; export default defineConfig(({ mode }) => { // When DISABLE_ADDITIONAL_FEATURES is false (or unset), enable proprietary features @@ -20,6 +21,15 @@ export default defineConfig(({ mode }) => { tsconfigPaths({ projects: [tsconfigProject], }), + viteStaticCopy({ + targets: [ + { + //provides static pdfium so embedpdf can run without cdn + src: 'node_modules/@embedpdf/pdfium/dist/pdfium.wasm', + dest: 'pdfium' + } + ] + }) ], server: { host: true, From f29d85565a82f278d199a9beeb2ff58a024744dc Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:42:16 +0000 Subject: [PATCH 05/24] Chore/v2/ctrlf (#5217) # 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) ### 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. --- .../components/viewer/CustomSearchLayer.tsx | 4 +- .../core/components/viewer/EmbedPdfViewer.tsx | 16 +- .../components/viewer/SearchAPIBridge.tsx | 46 +++- .../components/viewer/SearchInterface.tsx | 254 +++++++++++------- .../viewer/useViewerRightRailButtons.tsx | 12 +- frontend/src/core/contexts/ViewerContext.tsx | 19 ++ .../src/core/contexts/viewer/viewerActions.ts | 7 + 7 files changed, 244 insertions(+), 114 deletions(-) diff --git a/frontend/src/core/components/viewer/CustomSearchLayer.tsx b/frontend/src/core/components/viewer/CustomSearchLayer.tsx index 05b50ecab..29f3b528c 100644 --- a/frontend/src/core/components/viewer/CustomSearchLayer.tsx +++ b/frontend/src/core/components/viewer/CustomSearchLayer.tsx @@ -44,8 +44,10 @@ export function CustomSearchLayer({ } const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => { + if (!state) return; + // Auto-scroll to active search result - if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) { + if (state.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) { const activeResult = state.results[state.activeResultIndex]; if (activeResult) { const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 9ca8bac0a..94bff5a35 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -43,6 +43,8 @@ const EmbedPdfViewerContent = ({ isThumbnailSidebarVisible, toggleThumbnailSidebar, isBookmarkSidebarVisible, + isSearchInterfaceVisible, + searchInterfaceActions, zoomActions, panActions: _panActions, rotationActions: _rotationActions, @@ -184,7 +186,7 @@ const EmbedPdfViewerContent = ({ onZoomOut: zoomActions.zoomOut, }); - // Handle keyboard zoom shortcuts + // Handle keyboard shortcuts (zoom and search) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (!isViewerHovered) return; @@ -199,6 +201,16 @@ const EmbedPdfViewerContent = ({ // Ctrl+- for zoom out event.preventDefault(); zoomActions.zoomOut(); + } else if (event.key === 'f' || event.key === 'F') { + // Ctrl+F for search + event.preventDefault(); + if (isSearchInterfaceVisible) { + // If already open, trigger refocus event + window.dispatchEvent(new CustomEvent('refocus-search-input')); + } else { + // Open search interface + searchInterfaceActions.open(); + } } } }; @@ -207,7 +219,7 @@ const EmbedPdfViewerContent = ({ return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [isViewerHovered]); + }, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]); // Register checker for unsaved changes (annotations only for now) useEffect(() => { diff --git a/frontend/src/core/components/viewer/SearchAPIBridge.tsx b/frontend/src/core/components/viewer/SearchAPIBridge.tsx index 4b0eadd23..4003e1d0b 100644 --- a/frontend/src/core/components/viewer/SearchAPIBridge.tsx +++ b/frontend/src/core/components/viewer/SearchAPIBridge.tsx @@ -28,11 +28,13 @@ export function SearchAPIBridge() { if (!search) return; const unsubscribe = search.onSearchResultStateChange?.((state: any) => { + if (!state) return; + const newState = { - results: state?.results || null, - activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index + results: state.results || null, + activeIndex: (state.activeResultIndex || 0) + 1 // Convert to 1-based index }; - + setLocalState(prevState => { // Only update if state actually changed if (prevState.results !== newState.results || prevState.activeIndex !== newState.activeIndex) { @@ -52,16 +54,42 @@ export function SearchAPIBridge() { state: localState, api: { search: async (query: string) => { - search.startSearch(); - return search.searchAllPages(query); + if (search?.startSearch && search?.searchAllPages) { + search.startSearch(); + return search.searchAllPages(query); + } }, clear: () => { - search.stopSearch(); + try { + if (search?.stopSearch) { + search.stopSearch(); + } + } catch (error) { + console.warn('Error stopping search:', error); + } setLocalState({ results: null, activeIndex: 0 }); }, - next: () => search.nextResult(), - previous: () => search.previousResult(), - goToResult: (index: number) => search.goToResult(index), + next: () => { + try { + search?.nextResult?.(); + } catch (error) { + console.warn('Error navigating to next result:', error); + } + }, + previous: () => { + try { + search?.previousResult?.(); + } catch (error) { + console.warn('Error navigating to previous result:', error); + } + }, + goToResult: (index: number) => { + try { + search?.goToResult?.(index); + } catch (error) { + console.warn('Error going to result:', error); + } + }, } }); } diff --git a/frontend/src/core/components/viewer/SearchInterface.tsx b/frontend/src/core/components/viewer/SearchInterface.tsx index da6f6472a..eefac8154 100644 --- a/frontend/src/core/components/viewer/SearchInterface.tsx +++ b/frontend/src/core/components/viewer/SearchInterface.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { LocalIcon } from '@app/components/shared/LocalIcon'; @@ -12,7 +12,9 @@ interface SearchInterfaceProps { export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { const { t } = useTranslation(); const viewerContext = React.useContext(ViewerContext); - + const inputRef = useRef(null); + const searchTimeoutRef = useRef(null); + const searchState = viewerContext?.getSearchState(); const searchResults = searchState?.results; const searchActiveIndex = searchState?.activeIndex; @@ -26,6 +28,61 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { } | null>(null); const [isSearching, setIsSearching] = useState(false); + // Auto-focus search input when visible + useEffect(() => { + if (visible) { + inputRef.current?.focus(); + } + }, [visible]); + + // Listen for refocus event (when Ctrl+F pressed while already open) + useEffect(() => { + const handleRefocus = () => { + inputRef.current?.focus(); + inputRef.current?.select(); + }; + + window.addEventListener('refocus-search-input', handleRefocus); + return () => { + window.removeEventListener('refocus-search-input', handleRefocus); + }; + }, []); + + // Auto-search as user types (debounced) + useEffect(() => { + // Clear existing timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // If query is empty, clear search immediately + if (!searchQuery.trim()) { + searchActions?.clear(); + setResultInfo(null); + return; + } + + // Debounce search by 300ms + searchTimeoutRef.current = setTimeout(async () => { + if (searchQuery.trim() && searchActions) { + setIsSearching(true); + try { + await searchActions.search(searchQuery.trim()); + } catch (error) { + console.error('Search failed:', error); + } finally { + setIsSearching(false); + } + } + }, 300); + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [searchQuery, searchActions]); + // Monitor search state changes useEffect(() => { if (!visible) return; @@ -59,30 +116,21 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { return () => clearInterval(interval); }, [visible, searchResults, searchActiveIndex, searchQuery]); - const handleSearch = async (query: string) => { - if (!query.trim()) { - // If query is empty, clear the search - handleClearSearch(); - return; - } - - if (query.trim() && searchActions) { - setIsSearching(true); - try { - await searchActions.search(query.trim()); - } catch (error) { - console.error('Search failed:', error); - } finally { - setIsSearching(false); - } - } - }; - const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { - handleSearch(searchQuery); + // Navigate to next result on Enter + event.preventDefault(); + handleNext(); } else if (event.key === 'Escape') { onClose(); + } else if (event.key === 'ArrowDown') { + // Navigate to next result + event.preventDefault(); + handleNext(); + } else if (event.key === 'ArrowUp') { + // Navigate to previous result + event.preventDefault(); + handlePrevious(); } }; @@ -103,17 +151,17 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { // No longer need to sync with external API on mount - removed const handleJumpToResult = (index: number) => { - // Use context actions instead of window API - functionality simplified for now if (resultInfo && index >= 1 && index <= resultInfo.totalResults) { - // Note: goToResult functionality would need to be implemented in SearchAPIBridge - console.log('Jump to result:', index); + // Convert to 0-based index for the API + searchActions?.goToResult?.(index - 1); } }; const handleJumpToSubmit = () => { - const index = parseInt(jumpToValue); - if (index && resultInfo && index >= 1 && index <= resultInfo.totalResults) { + const index = parseInt(jumpToValue, 10); + if (!isNaN(index) && resultInfo && index >= 1 && index <= resultInfo.totalResults) { handleJumpToResult(index); + setJumpToValue(''); // Clear the input after jumping } }; @@ -123,7 +171,14 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { } }; - const _handleClose = () => { + const handleInputBlur = () => { + // Close popover on blur if no text is entered + if (!searchQuery.trim()) { + onClose(); + } + }; + + const handleCloseClick = () => { handleClearSearch(); onClose(); }; @@ -135,100 +190,99 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { padding: '0px' }} > - {/* Header */} - + {/* Header with close button */} + {t('search.title', 'Search PDF')} + + + {/* Search input */} { const newValue = e.currentTarget.value; setSearchQuery(newValue); - // If user clears the input, clear the search highlights - if (!newValue.trim()) { - handleClearSearch(); - } }} onKeyDown={handleKeyDown} + onBlur={handleInputBlur} style={{ flex: 1 }} rightSection={ - handleSearch(searchQuery)} - disabled={!searchQuery.trim() || isSearching} - loading={isSearching} - > - - + searchQuery.trim() && ( + + + + ) } /> - {/* Results info and navigation */} - {resultInfo && ( - - {resultInfo.totalResults === 0 ? ( - - {t('search.noResults', 'No results found')} - - ) : ( - - setJumpToValue(e.currentTarget.value)} - onKeyDown={handleJumpToKeyDown} - onBlur={handleJumpToSubmit} - placeholder={resultInfo.currentIndex.toString()} - style={{ width: '3rem' }} - type="number" - min="1" - max={resultInfo.totalResults} - /> - - of {resultInfo.totalResults} - - - )} - - {resultInfo.totalResults > 0 && ( - - - - - = resultInfo.totalResults} - aria-label="Next result" - > - - - - - - - )} + {/* Results info and navigation - always show */} + + + { + const newValue = e.currentTarget.value; + setJumpToValue(newValue); + + // Jump immediately as user types + const index = parseInt(newValue, 10); + if (resultInfo && !isNaN(index) && index >= 1 && index <= resultInfo.totalResults) { + handleJumpToResult(index); + } + }} + onKeyDown={handleJumpToKeyDown} + onBlur={() => setJumpToValue('')} // Clear on blur instead of submit + placeholder={(resultInfo?.currentIndex || 0).toString()} + style={{ width: '3rem' }} + type="number" + min="1" + max={resultInfo?.totalResults || 0} + disabled={!resultInfo || resultInfo.totalResults === 0} + /> + + of {resultInfo?.totalResults || 0} + - )} + + + + + + = resultInfo.totalResults} + aria-label="Next result" + > + + + + {/* Loading state */} {isSearching && ( diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index 995d7d095..9ef19dd71 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx @@ -36,7 +36,14 @@ export function useViewerRightRailButtons() { order: 10, render: ({ disabled }) => ( - +
@@ -52,7 +60,7 @@ export function useViewerRightRailButtons() {
- {}} /> +
diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 937c6067a..9217511ef 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -80,6 +80,14 @@ interface ViewerContextType { isBookmarkSidebarVisible: boolean; toggleBookmarkSidebar: () => void; + // Search interface visibility + isSearchInterfaceVisible: boolean; + searchInterfaceActions: { + open: () => void; + close: () => void; + toggle: () => void; + }; + // Annotation visibility toggle isAnnotationsVisible: boolean; toggleAnnotationsVisibility: () => void; @@ -145,6 +153,7 @@ export const ViewerProvider: React.FC = ({ children }) => { // UI state - only state directly managed by this context const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false); + const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false); const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true); const [isAnnotationMode, setIsAnnotationModeState] = useState(false); const [activeFileIndex, setActiveFileIndex] = useState(0); @@ -207,6 +216,12 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsBookmarkSidebarVisible(prev => !prev); }; + const searchInterfaceActions = { + open: () => setSearchInterfaceVisible(true), + close: () => setSearchInterfaceVisible(false), + toggle: () => setSearchInterfaceVisible(prev => !prev), + }; + const toggleAnnotationsVisibility = () => { setIsAnnotationsVisible(prev => !prev); }; @@ -294,6 +309,10 @@ export const ViewerProvider: React.FC = ({ children }) => { isBookmarkSidebarVisible, toggleBookmarkSidebar, + // Search interface + isSearchInterfaceVisible, + searchInterfaceActions, + // Annotation controls isAnnotationsVisible, toggleAnnotationsVisibility, diff --git a/frontend/src/core/contexts/viewer/viewerActions.ts b/frontend/src/core/contexts/viewer/viewerActions.ts index d32c5077c..882ba8aa8 100644 --- a/frontend/src/core/contexts/viewer/viewerActions.ts +++ b/frontend/src/core/contexts/viewer/viewerActions.ts @@ -52,6 +52,7 @@ export interface SearchActions { next: () => void; previous: () => void; clear: () => void; + goToResult: (index: number) => void; } export interface ExportActions { @@ -287,6 +288,12 @@ export function createViewerActions({ api.clear(); } }, + goToResult: (index: number) => { + const api = registry.current.search?.api; + if (api?.goToResult) { + api.goToResult(index); + } + }, }; const exportActions: ExportActions = { From eb3e57577ca51997dd801bf5018b90c6e8583ead Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:16:37 +0000 Subject: [PATCH 06/24] Bump version from 2.1.2 to 2.1.3 (#5224) # 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) ### 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. --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 2941c2eb5..90fde88e5 100644 --- a/build.gradle +++ b/build.gradle @@ -59,7 +59,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.1.2' + version = '2.1.3' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' From 5f072f87bb550320c0027448ffdf5b9cb783d84d Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:56:35 +0000 Subject: [PATCH 07/24] Account change details (#5190) ## Summary Accounts setting page to change a users password or username Fix huge bug were users can see admin settings due to hard code admin=true ## Testing - not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6934b8ecdbf08328a0951b46db77dfd2) --- .../public/locales/en-GB/translation.toml | 23 ++ .../core/components/shared/AppConfigModal.tsx | 2 +- .../core/components/shared/config/types.ts | 1 + frontend/src/core/services/accountService.ts | 10 + .../shared/config/configNavSections.tsx | 36 +++ .../config/configSections/AccountSection.tsx | 260 ++++++++++++++++++ .../config/configSections/GeneralSection.tsx | 53 ---- 7 files changed, 331 insertions(+), 54 deletions(-) create mode 100644 frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx delete mode 100644 frontend/src/proprietary/components/shared/config/configSections/GeneralSection.tsx diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 9be190247..83963987d 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -435,6 +435,24 @@ latestVersion = "Latest Version" checkForUpdates = "Check for Updates" viewDetails = "View Details" +[settings.security] +title = "Security" +description = "Update your password to keep your account secure." + +[settings.security.password] +subtitle = "Change your password. You will be logged out after updating." +required = "All fields are required." +mismatch = "New passwords do not match." +error = "Unable to update password. Please verify your current password and try again." +success = "Password updated successfully. Please sign in again." +current = "Current password" +currentPlaceholder = "Enter your current password" +new = "New password" +newPlaceholder = "Enter a new password" +confirm = "Confirm new password" +confirmPlaceholder = "Re-enter your new password" +update = "Update password" + [settings.hotkeys] title = "Keyboard Shortcuts" description = "Customize keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel." @@ -493,6 +511,10 @@ oldPassword = "Current Password" newPassword = "New Password" confirmNewPassword = "Confirm New Password" submit = "Submit Changes" +credsUpdated = "Account updated" +description = "Changes saved. Please log in again." +error = "Unable to update username. Please verify your password and try again." +changeUsername = "Update your username. You will be logged out after updating." [account] title = "Account Settings" @@ -5070,6 +5092,7 @@ loading = "Loading..." back = "Back" continue = "Continue" error = "Error" +save = "Save" [config.overview] title = "Application Configuration" diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index 6dd7491d4..42e6099e3 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -69,7 +69,7 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) }), []); // Get isAdmin and runningEE from app config - const isAdmin = true // config?.isAdmin ?? false; + const isAdmin = config?.isAdmin ?? false; const runningEE = config?.runningEE ?? false; const loginEnabled = config?.enableLogin ?? false; diff --git a/frontend/src/core/components/shared/config/types.ts b/frontend/src/core/components/shared/config/types.ts index d4bd9a83c..740e6130e 100644 --- a/frontend/src/core/components/shared/config/types.ts +++ b/frontend/src/core/components/shared/config/types.ts @@ -3,6 +3,7 @@ export const VALID_NAV_KEYS = [ 'preferences', 'notifications', 'connections', + 'account', 'general', 'people', 'teams', diff --git a/frontend/src/core/services/accountService.ts b/frontend/src/core/services/accountService.ts index 72d9f1873..cc8c2527f 100644 --- a/frontend/src/core/services/accountService.ts +++ b/frontend/src/core/services/accountService.ts @@ -56,4 +56,14 @@ export const accountService = { formData.append('newPassword', newPassword); await apiClient.post('/api/v1/user/change-password-on-login', formData); }, + + /** + * Change username + */ + async changeUsername(newUsername: string, currentPassword: string): Promise { + const formData = new FormData(); + formData.append('currentPasswordChangeUsername', currentPassword); + formData.append('newUsername', newUsername); + await apiClient.post('/api/v1/user/change-username', formData); + }, }; diff --git a/frontend/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/src/proprietary/components/shared/config/configNavSections.tsx index 6a64e5fbc..0073696f9 100644 --- a/frontend/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/src/proprietary/components/shared/config/configNavSections.tsx @@ -16,6 +16,8 @@ import AdminEndpointsSection from '@app/components/shared/config/configSections/ import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection'; import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection'; import ApiKeys from '@app/components/shared/config/configSections/ApiKeys'; +import AccountSection from '@app/components/shared/config/configSections/AccountSection'; +import GeneralSection from '@app/components/shared/config/configSections/GeneralSection'; /** * Hook version of proprietary config nav sections with proper i18n support @@ -30,6 +32,23 @@ export const useConfigNavSections = ( // Get the core sections (just Preferences) const sections = useCoreConfigNavSections(isAdmin, runningEE, loginEnabled); + // Add account management under Preferences + const preferencesSection = sections.find((section) => section.items.some((item) => item.key === 'general')); + if (preferencesSection) { + preferencesSection.items = preferencesSection.items.map((item) => + item.key === 'general' ? { ...item, component: } : item + ); + + if (loginEnabled) { + preferencesSection.items.push({ + key: 'account', + label: t('account.accountSettings', 'Account'), + icon: 'person-rounded', + component: + }); + } + } + // Add Admin sections if user is admin OR if login is disabled (but mark as disabled) if (isAdmin || !loginEnabled) { const requiresLogin = !loginEnabled; @@ -220,6 +239,23 @@ export const createConfigNavSections = ( // Get the core sections (just Preferences) const sections = createCoreConfigNavSections(isAdmin, runningEE, loginEnabled); + // Add account management under Preferences + const preferencesSection = sections.find((section) => section.items.some((item) => item.key === 'general')); + if (preferencesSection) { + preferencesSection.items = preferencesSection.items.map((item) => + item.key === 'general' ? { ...item, component: } : item + ); + + if (loginEnabled) { + preferencesSection.items.push({ + key: 'account', + label: 'Account', + icon: 'person-rounded', + component: + }); + } + } + // Add Admin sections if user is admin OR if login is disabled (but mark as disabled) if (isAdmin || !loginEnabled) { const requiresLogin = !loginEnabled; diff --git a/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx new file mode 100644 index 000000000..f627302b4 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx @@ -0,0 +1,260 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Alert, Button, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { alert as showToast } from '@app/components/toast'; +import { useAuth } from '@app/auth/UseSession'; +import { accountService } from '@app/services/accountService'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; + +const AccountSection: React.FC = () => { + const { t } = useTranslation(); + const { user, signOut } = useAuth(); + const [passwordModalOpen, setPasswordModalOpen] = useState(false); + const [usernameModalOpen, setUsernameModalOpen] = useState(false); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [passwordSubmitting, setPasswordSubmitting] = useState(false); + + const [currentPasswordForUsername, setCurrentPasswordForUsername] = useState(''); + const [newUsername, setNewUsername] = useState(''); + const [usernameError, setUsernameError] = useState(''); + const [usernameSubmitting, setUsernameSubmitting] = useState(false); + + const userIdentifier = useMemo(() => user?.email || user?.username || '', [user?.email, user?.username]); + + const redirectToLogin = useCallback(() => { + window.location.assign('/login'); + }, []); + + const handleLogout = useCallback(async () => { + try { + await signOut(); + } finally { + redirectToLogin(); + } + }, [redirectToLogin, signOut]); + + const handlePasswordSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!currentPassword || !newPassword || !confirmPassword) { + setPasswordError(t('settings.security.password.required', 'All fields are required.')); + return; + } + + if (newPassword !== confirmPassword) { + setPasswordError(t('settings.security.password.mismatch', 'New passwords do not match.')); + return; + } + + try { + setPasswordSubmitting(true); + setPasswordError(''); + + await accountService.changePassword(currentPassword, newPassword); + + showToast({ + alertType: 'success', + title: t('settings.security.password.success', 'Password updated successfully. Please sign in again.'), + }); + + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordModalOpen(false); + await handleLogout(); + } catch (err) { + const axiosError = err as { response?: { data?: { message?: string } } }; + setPasswordError( + axiosError.response?.data?.message || + t('settings.security.password.error', 'Unable to update password. Please verify your current password and try again.') + ); + } finally { + setPasswordSubmitting(false); + } + }; + + const handleUsernameSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!currentPasswordForUsername || !newUsername) { + setUsernameError(t('settings.security.password.required', 'All fields are required.')); + return; + } + + try { + setUsernameSubmitting(true); + setUsernameError(''); + + await accountService.changeUsername(newUsername, currentPasswordForUsername); + + showToast({ + alertType: 'success', + title: t('changeCreds.credsUpdated', 'Account updated'), + body: t('changeCreds.description', 'Changes saved. Please log in again.'), + }); + + setNewUsername(''); + setCurrentPasswordForUsername(''); + setUsernameModalOpen(false); + await handleLogout(); + } catch (err) { + const axiosError = err as { response?: { data?: { message?: string } } }; + setUsernameError( + axiosError.response?.data?.message || + t('changeCreds.error', 'Unable to update username. Please verify your password and try again.') + ); + } finally { + setUsernameSubmitting(false); + } + }; + + return ( + +
+ + {t('account.accountSettings', 'Account')} + + + {t('changeCreds.header', 'Update Your Account Details')} + +
+ + + + + {userIdentifier + ? t('settings.general.user', 'User') + ': ' + userIdentifier + : t('account.accountSettings', 'Account Settings')} + + + + + + + + + + + + + setPasswordModalOpen(false)} + title={t('settings.security.title', 'Change password')} + withinPortal + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > +
+ + + {t('settings.security.password.subtitle', 'Change your password. You will be logged out after updating.')} + + + {passwordError && ( + } color="red" variant="light"> + {passwordError} + + )} + + setCurrentPassword(event.currentTarget.value)} + required + /> + + setNewPassword(event.currentTarget.value)} + required + /> + + setConfirmPassword(event.currentTarget.value)} + required + /> + + + + + + +
+
+ + setUsernameModalOpen(false)} + title={t('account.changeUsername', 'Change username')} + withinPortal + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > +
+ + + {t('changeCreds.changeUsername', 'Update your username. You will be logged out after updating.')} + + + {usernameError && ( + } color="red" variant="light"> + {usernameError} + + )} + + setNewUsername(event.currentTarget.value)} + required + /> + + setCurrentPasswordForUsername(event.currentTarget.value)} + required + /> + + + + + + +
+
+
+ ); +}; + +export default AccountSection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/GeneralSection.tsx deleted file mode 100644 index 7894319cb..000000000 --- a/frontend/src/proprietary/components/shared/config/configSections/GeneralSection.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import { Stack, Text, Button } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { useAuth } from '@app/auth/UseSession'; -import { useNavigate } from 'react-router-dom'; -import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection'; - -/** - * Proprietary extension of GeneralSection that adds account management - */ -const GeneralSection: React.FC = () => { - const { t } = useTranslation(); - const { signOut, user } = useAuth(); - const navigate = useNavigate(); - - const handleLogout = async () => { - try { - await signOut(); - navigate('/login'); - } catch (error) { - console.error('Logout error:', error); - } - }; - - return ( - -
-
- {t('settings.general.title', 'General')} - - {t('settings.general.description', 'Configure general application preferences.')} - -
- - {user && ( - - - {t('settings.general.user', 'User')}: {user.email || user.username} - - - - )} -
- - {/* Render core general section preferences (without title since we show it above) */} - -
- ); -}; - -export default GeneralSection; From c86e2d68407acaea362a2872a2e405d5682cfbb6 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:38:32 +0000 Subject: [PATCH 08/24] Delete .github/README.md --- .github/README.md | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .github/README.md diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index 97cb44086..000000000 --- a/.github/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# CI Configuration - -## CI Lite Mode - -Skip non-essential CI workflows by setting a repository variable: - -**Settings → Secrets and variables → Actions → Variables → New repository variable** - -- Name: `CI_PROFILE` -- Value: `lite` - -Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs. From f4cc87144d7bf0d7bcb26fa7021e9aa63d09729a Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:44:15 +0000 Subject: [PATCH 09/24] Fix language codes in picker (#5233) # 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) ### 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. --- frontend/src/core/components/shared/LanguageSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/core/components/shared/LanguageSelector.tsx b/frontend/src/core/components/shared/LanguageSelector.tsx index 7eb35d1f7..4129e3209 100644 --- a/frontend/src/core/components/shared/LanguageSelector.tsx +++ b/frontend/src/core/components/shared/LanguageSelector.tsx @@ -173,7 +173,7 @@ const LanguageSelector: React.FC = ({ .sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB)) .map(([code, name]) => ({ value: code, - label: `${name} (${code})`, + label: name, })); // Hide the language selector if there's only one language option From 69ffd29bb5a9b07d72521e50f39bc920019dc4e2 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 12 Dec 2025 18:00:40 +0000 Subject: [PATCH 10/24] Fix German text for sign tool text entry (#5232) # Description of Changes Fix #5206 Required splitting out the logic for the text with font entry so that the labels are configurable from the call-site instead of all using the same ones for Sign. --- .../public/locales/de-DE/translation.toml | 4 +- .../annotation/shared/TextInputWithFont.tsx | 26 +++++---- .../components/annotation/tools/TextTool.tsx | 57 ------------------- .../components/tools/sign/SignSettings.tsx | 6 ++ 4 files changed, 24 insertions(+), 69 deletions(-) delete mode 100644 frontend/src/core/components/annotation/tools/TextTool.tsx diff --git a/frontend/public/locales/de-DE/translation.toml b/frontend/public/locales/de-DE/translation.toml index 274de764e..d48c424f4 100644 --- a/frontend/public/locales/de-DE/translation.toml +++ b/frontend/public/locales/de-DE/translation.toml @@ -6131,8 +6131,8 @@ tags = "text,anmerkung,beschriftung" applySignatures = "Text anwenden" [addText.text] -name = "Textinhalt" -placeholder = "Geben Sie den hinzuzufügenden Text ein" +name = "Text" +placeholder = "Text eingeben" fontLabel = "Schriftart" fontSizeLabel = "Schriftgröße" fontSizePlaceholder = "Schriftgröße eingeben oder wählen (8-200)" diff --git a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index c93a89be0..f3fecba7e 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from 'react'; import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; interface TextInputWithFontProps { @@ -13,8 +12,12 @@ interface TextInputWithFontProps { textColor?: string; onTextColorChange?: (color: string) => void; disabled?: boolean; - label?: string; - placeholder?: string; + label: string; + placeholder: string; + fontLabel: string; + fontSizeLabel: string; + fontSizePlaceholder: string; + colorLabel?: string; onAnyChange?: () => void; } @@ -30,9 +33,12 @@ export const TextInputWithFont: React.FC = ({ disabled = false, label, placeholder, + fontLabel, + fontSizeLabel, + fontSizePlaceholder, + colorLabel, onAnyChange }) => { - const { t } = useTranslation(); const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); const fontSizeCombobox = useCombobox(); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); @@ -66,8 +72,8 @@ export const TextInputWithFont: React.FC = ({ return ( { onTextChange(e.target.value); @@ -79,7 +85,7 @@ export const TextInputWithFont: React.FC = ({ {/* Font Selection */}