From 3c92cb7c2be29d1300ab28f765942f57c4502f86 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 10 Dec 2025 17:21:07 +0000 Subject: [PATCH 01/15] Improve styling of quick access bar (#5197) # Description of Changes Currently, the Quick Access Bar only renders well in Chrome. It's got all sorts of layout issues in Firefox and Safari. This PR attempts to retain the recent changes to make the bar thinner etc. but make it work better in all browsers. --- .../components/shared/AllToolsNavButton.tsx | 39 +++----- .../src/core/components/shared/FitText.tsx | 17 ++-- .../core/components/shared/QuickAccessBar.tsx | 71 +++++---------- .../quickAccessBar/ActiveToolButton.tsx | 6 +- .../shared/quickAccessBar/QuickAccessBar.css | 78 ++++++---------- .../quickAccessBar/QuickAccessButton.tsx | 90 +++++++++++++++++++ 6 files changed, 162 insertions(+), 139 deletions(-) create mode 100644 frontend/src/core/components/shared/quickAccessBar/QuickAccessButton.tsx diff --git a/frontend/src/core/components/shared/AllToolsNavButton.tsx b/frontend/src/core/components/shared/AllToolsNavButton.tsx index efa9a2a5d..1608d5bd3 100644 --- a/frontend/src/core/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/core/components/shared/AllToolsNavButton.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { ActionIcon } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { Tooltip } from '@app/components/shared/Tooltip'; import AppsIcon from '@mui/icons-material/AppsRounded'; @@ -7,6 +6,7 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; +import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton'; interface AllToolsNavButtonProps { activeButton: string; @@ -54,12 +54,6 @@ const AllToolsNavButton: React.FC = ({ handleUnlessSpecialClick(e, handleClick); }; - const iconNode = ( - - - - ); - return ( = ({ containerStyle={{ marginTop: "-1rem" }} maxWidth={200} > -
- + } + label={t("quickAccess.allTools", "Tools")} + isActive={isActive} onClick={handleNavClick} - size={isActive ? 'lg' : 'md'} - variant="subtle" - aria-label={t("quickAccess.allTools", "Tools")} - style={{ - backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)', - color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)', - border: 'none', - borderRadius: '8px', - textDecoration: 'none' - }} - className={isActive ? 'activeIconScale' : ''} - > - {iconNode} - - - {t("quickAccess.allTools", "Tools")} - + href={navProps.href} + ariaLabel={t("quickAccess.allTools", "Tools")} + textClassName="all-tools-text" + component="a" + />
); diff --git a/frontend/src/core/components/shared/FitText.tsx b/frontend/src/core/components/shared/FitText.tsx index efdb1c226..29abe6e0c 100644 --- a/frontend/src/core/components/shared/FitText.tsx +++ b/frontend/src/core/components/shared/FitText.tsx @@ -53,16 +53,15 @@ const FitText: React.FC = ({ const clampStyles: CSSProperties = { // Multi-line clamp with ellipsis fallback whiteSpace: lines === 1 ? 'nowrap' : 'normal', - overflow: 'visible', + overflow: 'hidden', textOverflow: 'ellipsis', - display: lines > 1 ? ('-webkit-box' as any) : undefined, - WebkitBoxOrient: lines > 1 ? ('vertical' as any) : undefined, - WebkitLineClamp: lines > 1 ? (lines as any) : undefined, - lineClamp: lines > 1 ? (lines as any) : undefined, - // Favor shrinking over breaking words; only break at natural spaces or softBreakChars - wordBreak: lines > 1 ? ('keep-all' as any) : ('normal' as any), - overflowWrap: 'normal', - hyphens: 'manual', + display: lines > 1 ? '-webkit-box' : undefined, + WebkitBoxOrient: lines > 1 ? 'vertical' : undefined, + WebkitLineClamp: lines > 1 ? lines : undefined, + // Favor breaking words when necessary to prevent overflow + wordBreak: lines > 1 ? 'break-word' : 'normal', + overflowWrap: lines > 1 ? 'break-word' : 'normal', + hyphens: lines > 1 ? 'auto' : 'manual', // fontSize expects rem values (e.g., 1.2, 0.9) to scale with global font size fontSize: fontSize ? `${fontSize}rem` : undefined, }; diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index 28efd60cb..e81afcda9 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -1,10 +1,9 @@ import React, { useState, useRef, forwardRef, useEffect } from "react"; -import { ActionIcon, Stack, Divider, Menu, Indicator } from "@mantine/core"; +import { Stack, Divider, Menu, Indicator } from "@mantine/core"; import { useTranslation } from 'react-i18next'; import { useNavigate, useLocation } from 'react-router-dom'; import LocalIcon from '@app/components/shared/LocalIcon'; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; -import { useIsOverflowing } from '@app/hooks/useIsOverflowing'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; @@ -18,6 +17,7 @@ import AppConfigModal from '@app/components/shared/AppConfigModal'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; import { requestStartTour } from '@app/constants/events'; +import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton'; import { isNavButtonActive, @@ -41,7 +41,6 @@ const QuickAccessBar = forwardRef((_, ref) => { const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); - const isOverflow = useIsOverflowing(scrollableRef); const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'; @@ -85,37 +84,27 @@ const QuickAccessBar = forwardRef((_, ref) => { } }; + const buttonStyle = getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); + // Render navigation button with conditional URL support return (
- handleClick(e), - 'aria-label': config.name - } : { - onClick: (e: React.MouseEvent) => handleClick(e), - 'aria-label': config.name - })} - size={isActive ? 'lg' : 'md'} - variant="subtle" - style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} - className={isActive ? 'activeIconScale' : ''} - data-testid={`${config.id}-button`} - > - - {config.icon} - - - - {config.name} - +
); }; @@ -150,6 +139,9 @@ const QuickAccessBar = forwardRef((_, ref) => { } } }, + ]; + + const middleButtons: ButtonConfig[] = [ { id: 'files', name: t("quickAccess.files", "Files"), @@ -160,8 +152,6 @@ const QuickAccessBar = forwardRef((_, ref) => { onClick: handleFilesButtonClick }, ]; - - const middleButtons: ButtonConfig[] = []; //TODO: Activity //{ // id: 'activity', @@ -211,13 +201,6 @@ const QuickAccessBar = forwardRef((_, ref) => { - {/* Conditional divider when overflowing */} - {isOverflow && ( - - )} {/* Scrollable content area */}
((_, ref) => { >
{/* Main navigation section */} - + {mainButtons.map((config, index) => ( {renderNavButton(config, index, config.id === 'read' || config.id === 'automate')} @@ -238,14 +221,6 @@ const QuickAccessBar = forwardRef((_, ref) => { ))} - {/* Divider after main buttons (creates gap) */} - {middleButtons.length === 0 && ( - - )} - {/* Middle section */} {middleButtons.length > 0 && ( <> @@ -253,7 +228,7 @@ const QuickAccessBar = forwardRef((_, ref) => { size="xs" className="content-divider" /> - + {middleButtons.map((config, index) => ( {renderNavButton(config, index)} @@ -267,7 +242,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
{/* Bottom section */} - + {bottomButtons.map((buttonConfig, index) => { // Handle help button with menu or direct action if (buttonConfig.id === 'help') { diff --git a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx index 55b79b26c..38ed45484 100644 --- a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx +++ b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx @@ -13,7 +13,7 @@ */ import React, { useEffect, useRef, useState } from 'react'; -import { ActionIcon } from '@mantine/core'; +import { ActionIcon, Divider } from '@mantine/core'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; @@ -195,6 +195,10 @@ const ActiveToolButton: React.FC = ({ setActiveButton, to className="button-text active current-tool-label" />
+
)}
diff --git a/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.css b/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.css index 2c4d3004a..6f0366fbc 100644 --- a/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.css +++ b/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.css @@ -38,9 +38,9 @@ /* Main container styles */ .quick-access-bar-main { background-color: var(--bg-muted); - width: 4rem; - min-width: 4rem; - max-width: 4rem; + width: 4.5rem; + min-width: 4.5rem; + max-width: 4.5rem; position: relative; z-index: 10; border-right: 1px solid var(--border-default); @@ -52,9 +52,9 @@ /* Rainbow mode container */ .quick-access-bar-main.rainbow-mode { background-color: var(--bg-muted); - width: 4rem; - min-width: 4rem; - max-width: 4rem; + width: 4.5rem; + min-width: 4.5rem; + max-width: 4.5rem; position: relative; z-index: 10; border-right: 1px solid var(--border-default); @@ -72,7 +72,7 @@ /* Header padding */ .quick-access-header { - padding: 1rem 0.5rem 0.5rem 0.5rem; + padding: 1rem 0.25rem 0.5rem 0.25rem; } .nav-header { @@ -84,14 +84,6 @@ gap: 0.5rem; } -/* Nav header divider */ -.nav-header-divider { - width: 3rem; - border-color: var(--color-gray-300); - margin-top: 0.5rem; - margin-bottom: 1rem; -} - /* All tools text styles */ .all-tools-text { margin-top: 0.75rem; @@ -116,16 +108,15 @@ .overflow-divider { width: 3rem; border-color: var(--color-gray-300); - margin: 0 0.5rem; + margin: 0 auto; + align-self: center; } /* Scrollable content area */ .quick-access-bar { - overflow-x: auto; - overflow-y: auto; - scrollbar-gutter: stable both-edges; - -webkit-overflow-scrolling: touch; - padding: 0 0.5rem 1rem 0.5rem; + overflow-x: hidden; + overflow-y: hidden; + padding: 0 0.25rem 1rem 0.25rem; } /* Scrollable content container */ @@ -143,21 +134,21 @@ text-rendering: optimizeLegibility; font-synthesis: none; text-align: center; - display: block; + width: 100%; } -/* Allow wrapping under the active top indicator; constrain to two lines */ +/* Allow wrapping under the active top indicator; constrain to three lines */ .current-tool-label { white-space: normal; overflow: hidden; - text-overflow: ellipsis; display: -webkit-box; - -webkit-line-clamp: 2; /* show up to two lines */ - line-clamp: 2; + -webkit-line-clamp: 3; /* show up to three lines */ + line-clamp: 3; -webkit-box-orient: vertical; - word-break: keep-all; - overflow-wrap: normal; - hyphens: manual; + word-break: break-all; + width: 100%; + box-sizing: border-box; + text-align: center; } .button-text.active { @@ -174,13 +165,14 @@ .content-divider { width: 3rem; border-color: var(--color-gray-300); - margin: 1rem 0; + margin: 1rem auto; + align-self: center; } /* Spacer */ .spacer { flex: 1; - margin-top: 1rem; + min-height: 1rem; } /* Config button text */ @@ -242,8 +234,6 @@ .current-tool-slot.visible { max-height: 8.25rem; /* icon + up to 3-line label + divider (132px) */ opacity: 1; - border-bottom: 1px solid var(--color-gray-300); - padding-bottom: 0.75rem; /* push border down for spacing */ margin-bottom: 1rem; } @@ -268,27 +258,9 @@ } } -/* Divider that animates growing from top */ +/* Divider under active tool indicator */ .current-tool-divider { width: 3rem; border-color: var(--color-gray-300); - margin: 0.5rem auto 0.5rem auto; - transform-origin: top; - animation: dividerGrowDown 350ms ease-out; - animation-fill-mode: both; -} - -@keyframes dividerGrowDown { - 0% { - transform: scaleY(0); - opacity: 0; - margin-top: 0; - margin-bottom: 0; - } - 100% { - transform: scaleY(1); - opacity: 1; - margin-top: 0.5rem; - margin-bottom: 0.5rem; - } + margin: 0.75rem auto 0; } diff --git a/frontend/src/core/components/shared/quickAccessBar/QuickAccessButton.tsx b/frontend/src/core/components/shared/quickAccessBar/QuickAccessButton.tsx new file mode 100644 index 000000000..c3ff23726 --- /dev/null +++ b/frontend/src/core/components/shared/quickAccessBar/QuickAccessButton.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { ActionIcon } from '@mantine/core'; +import FitText from '@app/components/shared/FitText'; + +interface QuickAccessButtonProps { + icon: React.ReactNode; + label: string; + isActive: boolean; + onClick?: (e: React.MouseEvent) => void; + href?: string; + ariaLabel: string; + textClassName?: 'button-text' | 'all-tools-text'; + backgroundColor?: string; + color?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; + component?: 'a' | 'button'; + dataTestId?: string; + dataTour?: string; +} + +const QuickAccessButton: React.FC = ({ + icon, + label, + isActive, + onClick, + href, + ariaLabel, + textClassName = 'button-text', + backgroundColor, + color, + size, + className, + component = 'button', + dataTestId, + dataTour, +}) => { + const buttonSize = size || (isActive ? 'lg' : 'md'); + const bgColor = backgroundColor || (isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)'); + const textColor = color || (isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)'); + + const actionIconProps = component === 'a' && href + ? { + component: 'a' as const, + href, + onClick, + 'aria-label': ariaLabel, + } + : { + onClick, + 'aria-label': ariaLabel, + }; + + return ( +
+ + {icon} + +
+ +
+
+ ); +}; + +export default QuickAccessButton; From d6a83fe6a1ef2a9997eddd62c0b032f3cccba733 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Wed, 10 Dec 2025 19:46:48 +0000 Subject: [PATCH 02/15] Fix: SSO Login Page (#5220) Users logging in via OAuth2 were redirected to Spring's default login form instead of the React frontend login page. This happened because the OAuth2 configuration used `.loginPage("/oauth2")` which pointed to the old Thymeleaf template. ### Testing (if applicable) - [x] 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/OpenApiConfig.java | 3 +- .../configuration/SecurityConfiguration.java | 36 +++---------------- .../security/service/EmailService.java | 3 +- 3 files changed, 9 insertions(+), 33 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java index 514b9231c..c85e78b73 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java @@ -66,7 +66,8 @@ public class OpenApiConfig { if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) { server = new Server().url(swaggerServerUrl).description("API Server"); } else { - // Use relative path so Swagger uses the current browser origin to avoid CORS issues when accessing via different ports + // Use relative path so Swagger uses the current browser origin to avoid CORS issues + // when accessing via different ports server = new Server().url("/").description("Current Server"); } openAPI.addServersItem(server); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 2a0cd5734..257d243ea 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -197,7 +197,6 @@ public class SecurityConfiguration { http.csrf(CsrfConfigurer::disable); if (loginEnabledValue) { - boolean v2Enabled = appConfig.v2Enabled(); http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) @@ -205,19 +204,9 @@ public class SecurityConfiguration { .addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class); http.sessionManagement( - sessionManagement -> { - if (v2Enabled) { + sessionManagement -> sessionManagement.sessionCreationPolicy( - SessionCreationPolicy.STATELESS); - } else { - sessionManagement - .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) - .maximumSessions(10) - .maxSessionsPreventsLogin(false) - .sessionRegistry(sessionRegistry) - .expiredUrl("/login?logout=true"); - } - }); + SessionCreationPolicy.STATELESS)); http.authenticationProvider(daoAuthenticationProvider()); http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache())); @@ -300,18 +289,7 @@ public class SecurityConfiguration { if (securityProperties.isOauth2Active()) { http.oauth2Login( oauth2 -> { - // v1: Use /oauth2 as login page for Thymeleaf templates - if (!v2Enabled) { - oauth2.loginPage("/oauth2"); - } - - // v2: Don't set loginPage, let default OAuth2 flow handle it - oauth2 - /* - This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. - If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser' - is set as true, else login fails with an error message advising the same. - */ + oauth2.loginPage("/login") .successHandler( new CustomOAuth2AuthenticationSuccessHandler( loginAttemptService, @@ -345,12 +323,8 @@ public class SecurityConfiguration { .saml2Login( saml2 -> { try { - // Only set login page for v1/Thymeleaf mode - if (!v2Enabled) { - saml2.loginPage("/saml2"); - } - - saml2.relyingPartyRegistrationRepository( + saml2.loginPage("/login") + .relyingPartyRegistrationRepository( saml2RelyingPartyRegistrations) .authenticationManager( new ProviderManager(authenticationProvider)) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index d4ecf8161..3ae079d0f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -226,7 +226,8 @@ public class EmailService { @Async public void sendPasswordChangedNotification( - String to, String username, String newPassword, String loginUrl) throws MessagingException { + String to, String username, String newPassword, String loginUrl) + throws MessagingException { String subject = "Your Stirling PDF password has been updated"; String passwordSection = From 2cd4175689975f5ac05cd54108223471f0ef6b47 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 11 Dec 2025 08:55:37 +0000 Subject: [PATCH 03/15] Fix Mac app not being able to open files with spaces in their name (#5218) # Description of Changes Fix #5189. Fix Mac app not being able to open files with spaces in their name, which was happening because the URL was not being decoded on input. --- frontend/src-tauri/src/lib.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index cb8faf04b..a08587cc1 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -152,15 +152,27 @@ pub fn run() { } #[cfg(target_os = "macos")] RunEvent::Opened { urls } => { + use urlencoding::decode; + add_log(format!("📂 Tauri file opened event: {:?}", urls)); let mut added_files = false; for url in urls { let url_str = url.as_str(); if url_str.starts_with("file://") { - let file_path = url_str.strip_prefix("file://").unwrap_or(url_str); + let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str); + + // Decode URL-encoded characters (%20 -> space, etc.) + let file_path = match decode(encoded_path) { + Ok(decoded) => decoded.into_owned(), + Err(e) => { + add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e)); + encoded_path.to_string() // Fallback to encoded path + } + }; + add_log(format!("📂 Processing opened file: {}", file_path)); - add_opened_file(file_path.to_string()); + add_opened_file(file_path); added_files = true; } } From 43eaa84a8fcb1d118492107ec55058f5a704be3c Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:20:39 +0000 Subject: [PATCH 04/15] fix tooltips on tab (#5219) # 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/Tooltip.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index db6bce849..980d2cfe7 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -256,10 +256,11 @@ export const Tooltip: React.FC = ({ (children.props as any)?.onBlur?.(e); return; } + clearTimers(); if (!isPinned) setOpen(false); (children.props as any)?.onBlur?.(e); }, - [isPinned, setOpen, children.props] + [isPinned, setOpen, children.props, clearTimers] ); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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 11/15] 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 12/15] 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 13/15] 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 14/15] 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 */}