Compare commits

...
13 Commits
Author SHA1 Message Date
EthanHealy01andGitHub bb201ef9c1 Chore/bump gradle version number (#5176)
bump version number
2025-12-05 23:22:32 +00:00
82dbcfbb9b SSO login fix (#5167)
Fixes bug where SSO login with custom providers caused an
`InvalidClientRegistrationIdException: Invalid Client Registration with
Id: oidc` errors.

  Root Cause:
- Backend: Redirect URI was hardcoded to `/login/oauth2/code/oidc`
regardless of provider registration ID
- Frontend: Unknown providers were mapped back to 'oidc' instead of
using actual provider ID

Closes #5141

---------

Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com>
Co-authored-by: Keon Chen <66115421+keonchennl@users.noreply.github.com>
2025-12-05 23:19:41 +00:00
EthanHealy01andGitHub 9fd8fd89ed add enum SERVER to list of valid licenses (#5172) 2025-12-05 13:18:23 +00:00
Keon ChenandGitHub 3a2370ea1f Update OCR setup guide link in LanguagePicker (#5162)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
Update the link to the doc page for language picker in OCR config
- Why the change was made
Old link doesn't work anymore
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ x] 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)
- [ x] I have performed a self-review of my own code
- [ x] My changes generate no new warnings

### Documentation

- [x ] 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)
- [x ] 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.
2025-12-04 21:35:11 +00:00
e7db714091 More fixes for automate (#5168)
# Description of Changes
Fix file missed in #5127 to use `apiClient` instead of `axios` directly

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-12-04 17:53:08 +00:00
c6b4a2b141 Desktop to match normal login screens (#5122)1
Also fixed issue with csrf
Also fixed issue with rust keychain

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-12-04 17:48:19 +00:00
Anthony StirlingandGitHub 7459463a3c V2 sso in server plan (#5158)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2025-12-03 21:12:29 +00:00
c9bf436895 couple of small fixes for text editor (#5155)
# Description of Changes

- Workbench.tsx: Allow PDF text editor to handle it's own custom state
(this is a bit of a hotfix, we're going to handle the PDF text editors
file upload flow better in a future PR).
- PdfTextEditorView.tsx: Added a dropzone and some helper text to upload
the first file.
- PdfTextEditorView.tsx: Hide document view when isConverting is true.
Prevents showing stale content from previous file during conversion.
- useTranslatedToolRegistry.tsx: Moved PDF Text Editor to top of
Recommended tools list. Increased visibility of the new feature.
- PdfTextEditor.tsx: Removed auto-navigation to PDF Editor workbench on
file selection. Stops the "jumpy" behavior when selecting files.
- HomePage.tsx: Check specifically for pdfTextEditor tool instead of any
custom workbench. Prevents auto-switch to viewer when uploading files
while in PDF Text Editor.

<img width="2056" height="1073" alt="Screenshot 2025-12-03 at 6 01
14 PM"
src="https://github.com/user-attachments/assets/dfc63a46-7991-486c-ba00-0ce7637502f5"
/>


---

## 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.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-12-03 20:37:23 +00:00
EthanHealy01andGitHub f8dbf171e1 Feature/v2/get all info on pdf (#5105)
# Description of Changes

- Addition of the get all info on PDF tool

---

## 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.
2025-12-03 20:02:42 +00:00
ConnorYohandGitHub e59c717dc0 Fixes state management loops around getting results V2 (#5153)
Makes sure settings step collapses in results step

Makes sure result step doesn't always reset even in development for
baseTool

Make sure result step doesn't reset for convert
2025-12-03 17:42:04 +00:00
ConnorYohandGitHub f2bffe2dc6 Fix-convert-V2 (#5147)
Custom processors can now return consume all inputs flag. This allows to
have many inputs to single output consumption

Fixed multi call conversion logic
2025-12-03 17:39:49 +00:00
Anthony StirlingandGitHub 5d827df08c Add onboarding bypass flag V2 version2 version 2 (#5151)
## Summary
- add a shared hook that honors a `bypassOnboarding` query parameter and
marks onboarding steps as completed for the session
- block onboarding orchestrator and UI elements when the bypass flag is
present so tours and popups stay hidden

## Testing
- ./gradlew build


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693059f866a8832891dd97f3d52ca5a0)
2025-12-03 17:17:22 +00:00
Anthony StirlingandGitHub bdb3c887f3 opensource text editor (#5146)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2025-12-03 12:55:34 +00:00
143 changed files with 4181 additions and 922 deletions
@@ -112,7 +112,6 @@ public class ApplicationProperties {
@Data
public static class Security {
private Boolean enableLogin;
private Boolean csrfDisabled;
private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2();
@@ -254,10 +254,7 @@ public class PostHogService {
properties,
"security_enableLogin",
applicationProperties.getSecurity().getEnableLogin());
addIfNotEmpty(
properties,
"security_csrfDisabled",
applicationProperties.getSecurity().getCsrfDisabled());
addIfNotEmpty(properties, "security_csrfDisabled", true);
addIfNotEmpty(
properties,
"security_loginAttemptCount",
@@ -34,7 +34,6 @@ public class InitialSetup {
public void init() throws IOException {
initUUIDKey();
initSecretKey();
initEnableCSRFSecurity();
initLegalUrls();
initSetAppVersion();
GeneralUtils.extractPipeline();
@@ -59,19 +58,6 @@ public class InitialSetup {
applicationProperties.getAutomaticallyGenerated().setKey(secretKey);
}
}
public void initEnableCSRFSecurity() throws IOException {
if (GeneralUtils.isVersionHigher(
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
if (!csrf) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
applicationProperties.getSecurity().setCsrfDisabled(false);
}
}
}
public void initLegalUrls() throws IOException {
// Initialize Terms and Conditions
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
@@ -95,7 +81,7 @@ public class InitialSetup {
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
|| "0.0.0".equals(existingVersion);
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
@@ -124,7 +124,6 @@ public class SettingsController {
ApplicationProperties.Security security = applicationProperties.getSecurity();
settings.put("enableLogin", security.getEnableLogin());
settings.put("csrfDisabled", security.getCsrfDisabled());
settings.put("loginMethod", security.getLoginMethod());
settings.put("loginAttemptCount", security.getLoginAttemptCount());
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
@@ -159,12 +158,6 @@ public class SettingsController {
.getSecurity()
.setEnableLogin((Boolean) settings.get("enableLogin"));
}
if (settings.containsKey("csrfDisabled")) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
applicationProperties
.getSecurity()
.setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
}
if (settings.containsKey("loginMethod")) {
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
applicationProperties
@@ -31,12 +31,10 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.security.config.PremiumEndpoint;
@Slf4j
@ConvertApi
@RequiredArgsConstructor
@PremiumEndpoint
public class ConvertPdfJsonController {
private final PdfJsonConversionService pdfJsonConversionService;
@@ -4,8 +4,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
@@ -13,6 +11,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
@Controller
@@ -63,9 +62,10 @@ public class ReactRoutingController {
}
}
@GetMapping(value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request)
throws IOException {
@GetMapping(
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
}
@@ -75,8 +75,7 @@ public class ReactRoutingController {
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request)
throws IOException {
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@@ -12,7 +12,6 @@
security:
enableLogin: true # set to 'true' to enable login
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
@@ -1,7 +1,6 @@
package stirling.software.proprietary.security.configuration;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -25,8 +24,6 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
@@ -47,7 +44,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
@@ -198,9 +194,7 @@ public class SecurityConfiguration {
http.cors(cors -> cors.disable());
}
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
http.csrf(CsrfConfigurer::disable);
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
@@ -210,48 +204,6 @@ public class SecurityConfiguration {
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http.csrf(
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String uri = request.getRequestURI();
// Ignore CSRF for auth endpoints
if (uri.startsWith("/api/v1/auth/")) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
// (return false)
if (apiKey == null || apiKey.trim().isEmpty()) {
return false;
}
// Validate API key using existing UserService
try {
Optional<User> user =
userService.getUserByApiKey(apiKey);
// If API key is valid, ignore CSRF (return
// true)
// If API key is invalid, don't ignore CSRF
// (return false)
return user.isPresent();
} catch (Exception e) {
// If there's any error validating the API
// key, don't ignore CSRF
return false;
}
})
.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler));
}
http.sessionManagement(
sessionManagement -> {
if (v2Enabled) {
@@ -331,7 +283,9 @@ public class SecurityConfiguration {
formLogin ->
formLogin
.loginPage("/login") // Redirect here when unauthenticated
.loginProcessingUrl("/perform_login") // Process form posts here (not /login)
.loginProcessingUrl(
"/perform_login") // Process form posts here (not
// /login)
.successHandler(
new CustomAuthenticationSuccessHandler(
loginAttemptService,
@@ -27,6 +27,7 @@ import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
@@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
@@ -77,12 +79,18 @@ public class CustomOAuth2AuthenticationSuccessHandler
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block OAuth login
log.warn(
"OAuth login blocked for existing user '{}' - not eligible (not grandfathered and no paid license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
log.warn(
"OAuth login blocked for new user '{}' - not eligible (no paid license for auto-creation)",
username);
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
@@ -67,10 +67,15 @@ public class OAuth2Configuration {
keycloakClientRegistration().ifPresent(registrations::add);
if (registrations.isEmpty()) {
log.error("No OAuth2 provider registered");
log.error("No OAuth2 provider registered - check your OAuth2 configuration");
throw new NoProviderFoundException("At least one OAuth2 provider must be configured.");
}
log.info(
"OAuth2 ClientRegistrationRepository created with {} provider(s): {}",
registrations.size(),
registrations.stream().map(ClientRegistration::getRegistrationId).toList());
return new InMemoryClientRegistrationRepository(registrations);
}
@@ -165,7 +170,6 @@ public class OAuth2Configuration {
githubClient.getUseAsUsername());
boolean isValid = validateProvider(github);
log.info("Initialised GitHub OAuth2 provider");
return isValid
? Optional.of(
@@ -208,7 +212,19 @@ public class OAuth2Configuration {
null,
null);
return !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider)
boolean isValid =
!isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider);
if (isValid) {
log.info(
"Initialised OIDC OAuth2 provider: registrationId='{}', issuer='{}', redirectUri='{}'",
name,
oauth.getIssuer(),
REDIRECT_URI_PATH + name);
} else {
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
}
return isValid
? Optional.of(
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
.registrationId(name)
@@ -217,7 +233,7 @@ public class OAuth2Configuration {
.scope(oidcProvider.getScopes())
.userNameAttributeName(oidcProvider.getUseAsUsername().getName())
.clientName(clientName)
.redirectUri(REDIRECT_URI_PATH + "oidc")
.redirectUri(REDIRECT_URI_PATH + name)
.authorizationGrantType(AUTHORIZATION_CODE)
.build())
: Optional.empty();
@@ -67,19 +67,25 @@ public class CustomSaml2AuthenticationSuccessHandler
boolean userExists = userService.usernameExistsIgnoreCase(username);
// Check if user is eligible for SAML (grandfathered or system has paid license)
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block SAML login
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
// User is not grandfathered and no ENTERPRISE license - block SAML login
log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
} else if (!licenseSettingsService.isSamlEligible(null)) {
// No existing user and no ENTERPRISE license -> block auto creation
log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
@@ -21,6 +21,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
@@ -343,17 +344,76 @@ public class UserLicenseSettingsService {
* @param user The user to check
* @return true if the user can use OAuth/SAML
*/
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
public boolean isOAuthEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("OAuth eligibility check for user: {}", username);
// Grandfathered users always have OAuth access
if (user != null && user.isOauthGrandfathered()) {
log.debug("User {} is grandfathered for OAuth", user.getUsername());
return true;
}
// Users can use OAuth/SAML only if system has ENTERPRISE license
boolean hasEnterpriseLicense = hasEnterpriseLicense();
log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense);
return hasEnterpriseLicense;
// todo: remove
if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting OAuth login - checking license requirement");
}
// Users can use OAuth with SERVER or ENTERPRISE license
boolean hasPaid = hasPaidLicense();
log.info(
"OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
hasPaid,
username,
hasPaid);
return hasPaid;
}
/**
* Checks if a user is eligible to use SAML authentication.
*
* <p>A user is eligible if:
*
* <ul>
* <li>They are grandfathered for OAuth (existing user before policy change), OR
* <li>The system has an ENTERPRISE license (SAML is enterprise-only)
* </ul>
*
* @param user The user to check
* @return true if the user can use SAML
*/
public boolean isSamlEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("SAML2 eligibility check for user: {}", username);
// Grandfathered users always have SAML access
if (user != null && user.isOauthGrandfathered()) {
log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username);
return true;
}
if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting SAML2 login - checking license requirement");
}
// Users can use SAML only with ENTERPRISE license
boolean hasEnterprise = hasEnterpriseLicense();
log.info(
"SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
hasEnterprise,
username,
hasEnterprise);
return hasEnterprise;
}
/**
@@ -495,8 +555,12 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
boolean hasPaid = (license == License.SERVER || license == License.ENTERPRISE);
log.info("License check result: type={}, requiresPaid=true, hasPaid={}", license, hasPaid);
return hasPaid;
}
/**
@@ -510,7 +574,19 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
log.info(
"License check result: type={}, requiresEnterprise=true, hasEnterprise={}",
license,
(license == License.ENTERPRISE));
if (license != License.ENTERPRISE) {
log.warn(
"SAML2 requires ENTERPRISE license but found: {}. SAML2 login will be blocked.",
license);
}
return license == License.ENTERPRISE;
}
}
@@ -0,0 +1,162 @@
package stirling.software.proprietary.security.oauth2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
/**
* Unit tests for OAuth2Configuration redirect URI logic.
*
* <p>These tests validate the critical fix for GitHub issue #5141: The redirect URI path segment
* MUST match the registration ID. Previously, the redirect URI was hardcoded to 'oidc', causing
* InvalidClientRegistrationIdException when custom provider names were used.
*
* <p>Note: These are conceptual tests documenting the expected behavior. Full integration testing
* with actual OIDC discovery would require: 1. Mock HTTP server for OIDC discovery endpoints 2.
* Valid OIDC configuration responses 3. Network mocking infrastructure
*/
class OAuth2ConfigurationTest {
/**
* Tests the redirect URI pattern for OIDC provider configurations.
*
* <p>Critical behavior (GitHub issue #5141 fix): The redirect URI path segment MUST match the
* registration ID. For example: - Provider name: "authentik" → Redirect URI:
* "/login/oauth2/code/authentik" - Provider name: "mycompany" → Redirect URI:
* "/login/oauth2/code/mycompany" - Provider name: "oidc" → Redirect URI:
* "/login/oauth2/code/oidc"
*
* <p>Previously, the redirect URI was hardcoded to 'oidc', causing Spring Security to look for
* a registration with ID 'oidc' when the provider redirected back. This caused
* InvalidClientRegistrationIdException when custom provider names were used.
*/
@Test
void testRedirectUriPattern_usesProviderNameNotHardcodedOidc() {
// Verify the redirect URI pattern constant
String redirectUriBase = "{baseUrl}/login/oauth2/code/";
// Test cases: provider name → expected redirect URI
String[][] testCases = {
{"authentik", redirectUriBase + "authentik"},
{"mycompany", redirectUriBase + "mycompany"},
{"oidc", redirectUriBase + "oidc"},
{"okta", redirectUriBase + "okta"},
{"auth0", redirectUriBase + "auth0"}
};
for (String[] testCase : testCases) {
String providerName = testCase[0];
String expectedRedirectUri = testCase[1];
// The fix ensures: .redirectUri(REDIRECT_URI_PATH + name)
// instead of: .redirectUri(REDIRECT_URI_PATH + "oidc")
String actualRedirectUri = redirectUriBase + providerName;
assertEquals(
expectedRedirectUri,
actualRedirectUri,
String.format(
"Redirect URI for provider '%s' must use provider name, not hardcoded 'oidc'",
providerName));
}
}
/**
* Documents the critical fix for OAuth2 redirect URI mismatch.
*
* <p>This test validates the logic that was changed in OAuth2Configuration.java line 220:
*
* <pre>
* // BEFORE (bug):
* .redirectUri(REDIRECT_URI_PATH + "oidc") // Always "oidc"
*
* // AFTER (fix):
* .redirectUri(REDIRECT_URI_PATH + name) // Dynamic provider name
* </pre>
*/
@Test
void testCriticalFix_redirectUriMatchesRegistrationId() {
// The redirect URI path segment extraction by Spring Security
String callbackUrl = "http://localhost:8080/login/oauth2/code/authentik?code=abc123";
// Spring extracts the path segment between "code/" and "?"
String extractedRegistrationId = extractRegistrationIdFromCallback(callbackUrl);
// The extracted ID MUST match an actual registration ID
assertEquals("authentik", extractedRegistrationId);
// If we had used hardcoded "oidc", the callback would be:
String buggyCallbackUrl = "http://localhost:8080/login/oauth2/code/oidc?code=abc123";
String buggyExtractedId = extractRegistrationIdFromCallback(buggyCallbackUrl);
// This would look for registration with ID "oidc" but we registered "authentik"
assertEquals("oidc", buggyExtractedId);
// The mismatch: registrationId="authentik", but Spring looks for "oidc"
// Result: InvalidClientRegistrationIdException
assertNotNull(buggyExtractedId, "This demonstrates the bug that was fixed");
}
/** Helper method simulating Spring's extraction of registration ID from callback URL */
private String extractRegistrationIdFromCallback(String callbackUrl) {
// Simplified version of what Spring Security does
// Actual: OAuth2AuthorizationRequestRedirectFilter extracts from path
String path = callbackUrl.split("\\?")[0];
String[] parts = path.split("/");
return parts[parts.length - 1]; // Last path segment
}
/**
* Validates the frontend-backend flow for custom provider names.
*
* <p>Complete flow: 1. Backend: Provider configured as "authentik" in settings.yml 2. Backend:
* ClientRegistration created with registrationId="authentik" 3. Backend: Redirect URI set to
* "{baseUrl}/login/oauth2/code/authentik" 4. Backend: Login endpoint returns providerList with
* "/oauth2/authorization/authentik" 5. Frontend: Extracts "authentik" from path and uses it for
* OAuth login 6. Frontend: Redirects to "/oauth2/authorization/authentik" 7. Backend: Spring
* Security redirects to provider with redirect_uri containing "authentik" 8. Provider:
* Redirects back to "/login/oauth2/code/authentik?code=..." 9. Backend: Spring Security
* extracts "authentik" from callback URL 10. Backend: Looks up ClientRegistration with ID
* "authentik" ✅ SUCCESS
*
* <p>If redirect URI was hardcoded to "oidc" (the bug): Step 7: Provider redirects to
* "/login/oauth2/code/oidc?code=..." Step 9: Spring Security looks for registration ID "oidc"
* Step 10: FAIL - No registration found with ID "oidc" (we registered "authentik") Result:
* InvalidClientRegistrationIdException
*/
@Test
void testEndToEndFlow_registrationIdConsistency() {
String providerName = "authentik";
// Step 2: Registration ID
String registrationId = providerName;
assertEquals("authentik", registrationId);
// Step 3: Redirect URI (MUST use same name)
String redirectUri = "{baseUrl}/login/oauth2/code/" + providerName;
assertEquals("{baseUrl}/login/oauth2/code/authentik", redirectUri);
// Step 4: Provider list endpoint
String authorizationPath = "/oauth2/authorization/" + providerName;
assertEquals("/oauth2/authorization/authentik", authorizationPath);
// Step 5: Frontend extracts provider ID
String frontendProviderId =
authorizationPath.substring(authorizationPath.lastIndexOf('/') + 1);
assertEquals("authentik", frontendProviderId);
// Step 6-8: OAuth flow (external)
// Step 9: Callback URL from provider
String callbackUrl =
"http://localhost:8080/login/oauth2/code/" + providerName + "?code=abc123";
String extractedId = extractRegistrationIdFromCallback(callbackUrl);
// Step 10: Registration lookup
assertEquals(
registrationId,
extractedId,
"Registration ID from callback MUST match original registration ID");
}
}
@@ -267,4 +267,222 @@ class UserLicenseSettingsServiceTest {
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
// ===== OAuth Eligibility Tests =====
@Test
void isOAuthEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for OAuth");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithServerLicense_returnsTrue() {
// Non-grandfathered user with SERVER license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Non-grandfathered user with SERVER license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isOAuthEligible(user);
assertEquals(
true, result, "Non-grandfathered user with ENTERPRISE license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without paid license should NOT be eligible");
}
@Test
void isOAuthEligible_newUserWithServerLicense_returnsTrue() {
// New user (null) with SERVER license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(null);
assertEquals(
true, result, "New user with SERVER license should be eligible for auto-creation");
}
@Test
void isOAuthEligible_newUserWithNoLicense_returnsFalse() {
// New user (null) without license should NOT be eligible
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(null);
assertEquals(
false,
result,
"New user without paid license should NOT be eligible for auto-creation");
}
@Test
void isOAuthEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, OAuth should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isOAuthEligible(user);
assertEquals(
false, result, "OAuth should be blocked when LicenseKeyChecker is unavailable");
}
// ===== SAML Eligibility Tests =====
@Test
void isSamlEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible for SAML regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(user);
assertEquals(
true,
result,
"Non-grandfathered user with ENTERPRISE license should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithServerLicense_returnsFalse() {
// Non-grandfathered user with SERVER license should NOT be eligible for SAML
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user with SERVER license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without ENTERPRISE license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_newUserWithEnterpriseLicense_returnsTrue() {
// New user (null) with ENTERPRISE license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(null);
assertEquals(
true,
result,
"New user with ENTERPRISE license should be eligible for SAML auto-creation");
}
@Test
void isSamlEligible_newUserWithServerLicense_returnsFalse() {
// New user (null) with SERVER license should NOT be eligible for SAML
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(null);
assertEquals(
false,
result,
"New user with SERVER license should NOT be eligible for SAML (requires ENTERPRISE)");
}
@Test
void isSamlEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, SAML should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isSamlEligible(user);
assertEquals(false, result, "SAML should be blocked when LicenseKeyChecker is unavailable");
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.0.3'
version = '2.1.1'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+41 -16
View File
@@ -456,6 +456,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -499,6 +500,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -579,6 +581,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.5.0",
"@embedpdf/models": "1.5.0"
@@ -678,6 +681,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -694,6 +698,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -711,6 +716,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -764,6 +770,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -798,6 +805,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -834,6 +842,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -909,6 +918,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -1064,6 +1074,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1107,6 +1118,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2137,6 +2149,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2187,6 +2200,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2254,6 +2268,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@@ -3186,6 +3201,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@@ -3304,7 +3320,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -4081,6 +4096,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4409,6 +4425,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4419,6 +4436,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4488,6 +4506,7 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@@ -5201,7 +5220,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@@ -5211,7 +5229,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5222,7 +5239,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@@ -5235,7 +5251,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5262,6 +5277,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5669,7 +5685,6 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -5946,6 +5961,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6993,7 +7009,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7388,6 +7405,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7558,6 +7576,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7724,8 +7743,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
@@ -7790,7 +7808,6 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -8881,6 +8898,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9357,7 +9375,6 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@@ -9678,6 +9695,7 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@@ -10264,8 +10282,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -11411,6 +11428,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11690,6 +11708,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -12072,6 +12091,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12081,6 +12101,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13592,7 +13613,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -13801,6 +13821,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14102,6 +14123,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14183,6 +14205,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14387,6 +14410,7 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14538,6 +14562,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14551,6 +14576,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -15162,8 +15188,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
+99 -2
View File
@@ -3036,6 +3036,91 @@ title = "Get Info on PDF"
header = "Get Info on PDF"
submit = "Get Info"
downloadJson = "Download JSON"
processing = "Extracting information..."
results = "Results"
noResults = "Run the tool to generate a report."
downloads = "Downloads"
noneDetected = "None detected"
indexTitle = "Index"
[getPdfInfo.report]
entryLabel = "Full information summary"
shortTitle = "PDF Information"
[getPdfInfo.sections]
metadata = "Metadata"
formFields = "Form Fields"
basicInfo = "Basic Info"
documentInfo = "Document Info"
compliance = "Compliance"
encryption = "Encryption"
permissions = "Permissions"
other = "Other"
perPageInfo = "Per Page Info"
tableOfContents = "Table of Contents"
[getPdfInfo.other]
attachments = "Attachments"
embeddedFiles = "Embedded Files"
javaScript = "JavaScript"
layers = "Layers"
structureTree = "StructureTree"
xmp = "XMPMetadata"
[getPdfInfo.perPage]
size = "Size"
annotations = "Annotations"
images = "Images"
links = "Links"
fonts = "Fonts"
xobjects = "XObject Counts"
multimedia = "Multimedia"
[getPdfInfo.summary]
pages = "Pages"
fileSize = "File Size"
pdfVersion = "PDF Version"
language = "Language"
title = "PDF Summary"
author = "Author"
created = "Created"
modified = "Modified"
permsAll = "All Permissions Allowed"
permsRestricted = "{{count}} restrictions"
permsMixed = "Some permissions restricted"
hasCompliance = "Has compliance standards"
noCompliance = "No Compliance Standards"
basic = "Basic Information"
documentInfo = "Document Information"
securityTitle = "Security Status"
technical = "Technical"
overviewTitle = "PDF Overview"
[getPdfInfo.summary.security]
encrypted = "Encrypted PDF - Password protection present"
unencrypted = "Unencrypted PDF - No password protection"
[getPdfInfo.summary.tech]
images = "Images"
fonts = "Fonts"
formFields = "Form Fields"
embeddedFiles = "Embedded Files"
javaScript = "JavaScript"
layers = "Layers"
bookmarks = "Bookmarks"
multimedia = "Multimedia"
[getPdfInfo.summary.overview]
untitled = "an untitled document"
unknown = "Unknown Author"
text = "This is a {{pages}}-page PDF titled {{title}} created by {{author}} (PDF version {{version}})."
[getPdfInfo.error]
partial = "Some files could not be processed."
unexpected = "Unexpected error during extraction."
[getPdfInfo.status]
complete = "Extraction complete"
[extractPage]
tags = "extract"
@@ -3454,8 +3539,8 @@ signinTitle = "Please sign in"
ssoSignIn = "Login via Single Sign-on"
oAuth2AutoCreateDisabled = "OAUTH2 Auto-Create User Disabled"
oAuth2AdminBlockedUser = "Registration or logging in of non-registered users is currently blocked. Please contact the administrator."
oAuth2RequiresLicense = "OAuth/SSO login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan."
saml2RequiresLicense = "SAML login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan."
oAuth2RequiresLicense = "OAuth/SSO login requires a Server or Enterprise license. Please contact the administrator to upgrade your plan."
saml2RequiresLicense = "SAML login requires an Enterprise license. Please contact the administrator to upgrade your plan."
maxUsersReached = "Maximum number of users reached for your current license. Please contact the administrator to upgrade your plan or add more seats."
oauth2RequestNotFound = "Authorization request not found"
oauth2InvalidUserInfoResponse = "Invalid User Info Response"
@@ -5816,6 +5901,7 @@ subtitle = "Sign in with your Stirling account"
[setup.selfhosted]
title = "Sign in to Server"
subtitle = "Enter your server credentials"
link = "or connect to a self-hosted account"
[setup.server]
title = "Connect to Server"
@@ -5834,6 +5920,14 @@ description = "Enter the full URL of your self-hosted Stirling PDF server"
emptyUrl = "Please enter a server URL"
unreachable = "Could not connect to server"
testFailed = "Connection test failed"
configFetch = "Failed to fetch server configuration. Please check the URL and try again."
[setup.server.error.securityDisabled]
title = "Login Not Enabled"
body = "This server does not have login enabled. To connect to this server, you must enable authentication:"
step1 = "Set DOCKER_ENABLE_SECURITY=true in your environment"
step2 = "Or set security.enableLogin=true in settings.yml"
step3 = "Restart the server"
[setup.login]
title = "Sign In"
@@ -5906,6 +6000,7 @@ earlyAccess = "Early Access"
reset = "Reset Changes"
downloadJson = "Download JSON"
generatePdf = "Generate PDF"
saveChanges = "Save Changes"
[pdfTextEditor.options.autoScaleText]
title = "Auto-scale text to fit boxes"
@@ -5943,6 +6038,8 @@ alpha = "This alpha viewer is still evolving—certain fonts, colours, transpare
[pdfTextEditor.empty]
title = "No document loaded"
subtitle = "Load a PDF or JSON file to begin editing text content."
dropzone = "Drag and drop a PDF or JSON file here, or click to browse"
dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse"
[pdfTextEditor.welcomeBanner]
title = "Welcome to PDF Text Editor (Early Access)"
+18 -1
View File
@@ -2152,7 +2152,11 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"log",
"security-framework 2.11.1",
"security-framework 3.5.1",
"windows-sys 0.60.2",
"zeroize",
]
@@ -2378,7 +2382,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
@@ -3841,6 +3845,19 @@ dependencies = [
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
+1 -1
View File
@@ -32,7 +32,7 @@ tauri-plugin-http = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-store = "2.1.0"
tauri-plugin-opener = "2.0.0"
keyring = "3.6.1"
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
tokio = { version = "1.0", features = ["time", "sync"] }
reqwest = { version = "0.11", features = ["json"] }
tiny_http = "0.12"
+36 -31
View File
@@ -1,4 +1,4 @@
use keyring::Entry;
use keyring::{Entry};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tauri::AppHandle;
@@ -21,53 +21,70 @@ pub struct UserInfo {
}
fn get_keyring_entry() -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| format!("Failed to access keyring: {}", e))
log::debug!("Creating keyring entry with service='{}' username='{}'", KEYRING_SERVICE, KEYRING_TOKEN_KEY);
let entry = Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| {
log::error!("Failed to create keyring entry: {}", e);
format!("Failed to access keyring: {}", e)
})?;
log::debug!("Keyring entry created successfully");
Ok(entry)
}
#[tauri::command]
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
log::info!("Saving auth token to keyring");
if token.is_empty() {
log::warn!("Attempted to save empty auth token");
return Err("Token cannot be empty".to_string());
}
let entry = get_keyring_entry()?;
entry
.set_password(&token)
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
.map_err(|e| {
log::error!("Failed to set password in keyring: {}", e);
format!("Failed to save token to keyring: {}", e)
})?;
// Verify the save worked
match entry.get_password() {
Ok(retrieved_token) => {
if retrieved_token != token {
log::error!("Token verification failed: Retrieved token doesn't match");
return Err("Token verification failed after save".to_string());
}
}
Err(e) => {
log::error!("Token verification failed: {}", e);
return Err(format!("Token verification failed: {}", e));
}
}
log::info!("Auth token saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
log::debug!("Retrieving auth token from keyring");
let entry = get_keyring_entry()?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
Err(e) => {
log::error!("Failed to retrieve token from keyring: {}", e);
Err(format!("Failed to retrieve token: {}", e))
},
}
}
#[tauri::command]
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing auth token from keyring");
let entry = get_keyring_entry()?;
// Delete the token - ignore error if it doesn't exist
match entry.delete_credential() {
Ok(_) => {
log::info!("Auth token cleared successfully");
Ok(())
}
Err(keyring::Error::NoEntry) => {
log::info!("Auth token was already cleared");
Ok(())
}
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to clear token: {}", e)),
}
}
@@ -78,8 +95,6 @@ pub async fn save_user_info(
username: String,
email: Option<String>,
) -> Result<(), String> {
log::info!("Saving user info for: {}", username);
let user_info = UserInfo { username, email };
let store = app_handle
@@ -96,7 +111,6 @@ pub async fn save_user_info(
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info saved successfully");
Ok(())
}
@@ -117,8 +131,6 @@ pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, St
#[tauri::command]
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
@@ -129,7 +141,6 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info cleared successfully");
Ok(())
}
@@ -186,12 +197,8 @@ pub async fn login(
supabase_key: String,
saas_server_url: String,
) -> Result<LoginResponse, String> {
log::info!("Login attempt for user: {} to server: {}", username, server_url);
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
// Compare against the configured SaaS server URL
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" });
// Create HTTP client
let client = reqwest::Client::new();
@@ -248,8 +255,6 @@ pub async fn login(
.or_else(|| email.clone())
.unwrap_or_else(|| username);
log::info!("Supabase login successful for user: {}", username);
Ok(LoginResponse {
token: login_response.access_token,
username,
@@ -71,6 +71,20 @@ export default function Workbench() {
};
const renderMainContent = () => {
// Check for custom workbench views first
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
// PDF text editor handles its own empty state (shows dropzone when no document)
const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor';
if (handlesOwnEmptyState || activeFiles.length > 0) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
}
// For base workbenches (or custom views that don't handle empty state), show landing page when no files
if (activeFiles.length === 0) {
return (
<LandingPage
@@ -143,15 +157,6 @@ export default function Workbench() {
);
default:
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
return <LandingPage />;
}
};
@@ -6,6 +6,7 @@ import { isAuthRoute } from '@app/constants/routes';
import { dispatchTourState } from '@app/constants/events';
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
import {
@@ -29,6 +30,7 @@ export default function Onboarding() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
const { state, actions } = useOnboardingOrchestrator();
const serverExperience = useServerExperience();
const onAuthRoute = isAuthRoute(location.pathname);
@@ -227,6 +229,10 @@ export default function Onboarding() {
return modalSlides.findIndex((step) => step.id === currentStep.id);
}, [activeFlow, currentStep]);
if (bypassOnboarding) {
return null;
}
if (onAuthRoute) {
return null;
}
@@ -17,6 +17,7 @@ import {
migrateFromLegacyPreferences,
} from '@app/components/onboarding/orchestrator/onboardingStorage';
import { accountService } from '@app/services/accountService';
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested';
@@ -142,6 +143,7 @@ export function useOnboardingOrchestrator(
const serverExperience = useServerExperience();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState)
@@ -213,7 +215,8 @@ export function useOnboardingOrchestrator(
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
const loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
const shouldBlockOnboarding = isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
const shouldBlockOnboarding =
bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
...serverExperience,
@@ -0,0 +1,70 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
const SESSION_KEY = 'onboarding::bypass-all';
const PARAM_KEY = 'bypassOnboarding';
function isTruthy(value: string | null): boolean {
return value?.toLowerCase() === 'true';
}
function readStoredBypass(): boolean {
if (typeof window === 'undefined') return false;
try {
return sessionStorage.getItem(SESSION_KEY) === 'true';
} catch {
return false;
}
}
function setStoredBypass(enabled: boolean): void {
if (typeof window === 'undefined') return;
try {
if (enabled) {
sessionStorage.setItem(SESSION_KEY, 'true');
} else {
sessionStorage.removeItem(SESSION_KEY);
}
} catch {
// Ignore storage errors to avoid blocking the bypass flow
}
}
/**
* Detects the `bypassOnboarding` query parameter and stores it in session storage
* so that onboarding remains disabled while the app is open. Also marks all steps
* as seen to ensure any dependent UI elements remain hidden.
*/
export function useBypassOnboarding(): boolean {
const location = useLocation();
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
const stepsMarkedRef = useRef(false);
const shouldBypassFromSearch = useMemo(() => {
try {
const params = new URLSearchParams(location.search);
return isTruthy(params.get(PARAM_KEY));
} catch {
return false;
}
}, [location.search]);
useEffect(() => {
const fromStorage = readStoredBypass();
const nextBypass = shouldBypassFromSearch || fromStorage;
setBypassOnboarding(nextBypass);
if (nextBypass) {
setStoredBypass(true);
}
}, [shouldBypassFromSearch]);
useEffect(() => {
if (!bypassOnboarding || stepsMarkedRef.current) return;
stepsMarkedRef.current = true;
ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id));
}, [bypassOnboarding]);
return bypassOnboarding;
}
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
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';
@@ -20,21 +21,36 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
}) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
const handleClick = () => {
const performNavigation = () => {
setActiveButton('tools');
// Preserve existing behavior used in QuickAccessBar header
handleReaderToggle();
handleBackToTools();
};
const handleClick = () => {
if (hasUnsavedChanges) {
navigationActions.requestNavigation(performNavigation);
return;
}
performNavigation();
};
// Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const navProps = getHomeNavigation();
const handleNavClick = (e: React.MouseEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
navigationActions.requestNavigation(performNavigation);
return;
}
handleUnlessSpecialClick(e, handleClick);
};
@@ -12,7 +12,7 @@ interface NavigationWarningModalProps {
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
const handleKeepWorking = () => {
@@ -41,7 +41,9 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
};
const BUTTON_WIDTH = "10rem";
if (!hasUnsavedChanges) {
// Only show modal if there are unsaved changes AND there's an actual pending navigation
// This prevents the modal from showing due to spurious state updates
if (!hasUnsavedChanges || !pendingNavigation) {
return null;
}
@@ -7,6 +7,7 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
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';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar';
@@ -32,6 +33,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const licenseAlert = useLicenseAlert();
@@ -58,7 +61,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
};
// Helper function to render navigation buttons with URL support
const renderNavButton = (config: ButtonConfig, index: number) => {
const renderNavButton = (config: ButtonConfig, index: number, shouldGuardNavigation = false) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
@@ -67,6 +70,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
: null;
const handleClick = (e?: React.MouseEvent) => {
// If there are unsaved changes and this button should guard navigation, show warning modal
if (shouldGuardNavigation && hasUnsavedChanges) {
e?.preventDefault();
navigationActions.requestNavigation(() => {
config.onClick();
});
return;
}
if (navProps && e) {
handleUnlessSpecialClick(e, config.onClick);
} else {
@@ -89,7 +100,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick(),
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
})}
size={isActive ? 'lg' : 'md'}
@@ -222,7 +233,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<Stack gap="lg" align="center">
{mainButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
{renderNavButton(config, index, config.id === 'read' || config.id === 'automate')}
</React.Fragment>
))}
</Stack>
@@ -97,6 +97,7 @@ export const OAUTH2_PROVIDERS: Provider[] = [
icon: 'key-rounded',
type: 'oauth2',
scope: 'SSO',
businessTier: false, // Server tier - OAuth2/OIDC SSO
fields: [
{
key: 'issuer',
@@ -141,6 +142,7 @@ export const GENERIC_OAUTH2_PROVIDER: Provider = {
icon: 'link-rounded',
type: 'oauth2',
scope: 'SSO',
businessTier: false, // Server tier - OAuth2/OIDC SSO
fields: [
{
key: 'enabled',
@@ -262,8 +264,8 @@ export const SAML2_PROVIDER: Provider = {
name: 'SAML2',
icon: 'verified-user-rounded',
type: 'saml2',
scope: 'SSO',
businessTier: true,
scope: 'SSO (SAML)',
businessTier: true, // Enterprise tier - SAML only
fields: [
{
key: 'enabled',
@@ -16,6 +16,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
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 FitText from '@app/components/shared/FitText';
@@ -31,6 +32,8 @@ const NAV_IDS = ['read', 'sign', 'automate'];
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, tooltipPosition = 'right' }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
@@ -150,10 +153,16 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
component="a"
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => {
const performNavigation = () => {
setActiveButton('tools');
handleBackToTools();
});
};
if (hasUnsavedChanges) {
e.preventDefault();
navigationActions.requestNavigation(performNavigation);
return;
}
handleUnlessSpecialClick(e, performNavigation);
}}
size={'lg'}
variant="subtle"
@@ -0,0 +1,128 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { Badge, Divider, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type {
PdfInfoReportData,
PdfInfoReportEntry,
PdfInfoBackendData,
ParsedPdfSections,
} from '@app/types/getPdfInfo';
import '@app/components/tools/validateSignature/reportView/styles.css';
import SummarySection from '@app/components/tools/getPdfInfo/sections/SummarySection';
import KeyValueSection from '@app/components/tools/getPdfInfo/sections/KeyValueSection';
import TableOfContentsSection from '@app/components/tools/getPdfInfo/sections/TableOfContentsSection';
import OtherSection from '@app/components/tools/getPdfInfo/sections/OtherSection';
import PerPageSection from '@app/components/tools/getPdfInfo/sections/PerPageSection';
/** Valid section anchor IDs for navigation */
const VALID_ANCHORS = new Set([
'summary', 'metadata', 'formFields', 'basicInfo', 'documentInfo',
'compliance', 'encryption', 'permissions', 'toc', 'other', 'perPage',
]);
interface GetPdfInfoReportViewProps {
data: PdfInfoReportData & { scrollTo?: string | null };
}
const GetPdfInfoReportView: React.FC<GetPdfInfoReportViewProps> = ({ data }) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const entry: PdfInfoReportEntry | null = data.entries[0] ?? null;
useEffect(() => {
if (!data.scrollTo || !VALID_ANCHORS.has(data.scrollTo)) return;
const anchor = data.scrollTo;
const container = containerRef.current;
const el = container?.querySelector<HTMLElement>(`#${anchor}`);
if (el && container) {
// Calculate scroll position with 4rem buffer from top
const bufferPx = parseFloat(getComputedStyle(document.documentElement).fontSize) * 4;
const elementTop = el.getBoundingClientRect().top;
const containerTop = container.getBoundingClientRect().top;
const currentScroll = container.scrollTop;
const targetScroll = currentScroll + (elementTop - containerTop) - bufferPx;
container.scrollTo({ top: Math.max(0, targetScroll), behavior: 'smooth' });
// Flash highlight the section
el.classList.remove('section-flash-highlight');
void el.offsetWidth; // Force reflow
el.classList.add('section-flash-highlight');
setTimeout(() => el.classList.remove('section-flash-highlight'), 1500);
}
}, [data.scrollTo]);
const sections = useMemo((): ParsedPdfSections => {
const raw: PdfInfoBackendData = entry?.data ?? {};
return {
metadata: raw.Metadata ?? null,
formFields: raw.FormFields ?? raw['Form Fields'] ?? null,
basicInfo: raw.BasicInfo ?? raw['Basic Info'] ?? null,
documentInfo: raw.DocumentInfo ?? raw['Document Info'] ?? null,
compliance: raw.Compliancy ?? raw.Compliance ?? null,
encryption: raw.Encryption ?? null,
permissions: raw.Permissions ?? null,
toc: raw['Bookmarks/Outline/TOC'] ?? raw['Table of Contents'] ?? null,
other: raw.Other ?? null,
perPage: raw.PerPageInfo ?? raw['Per Page Info'] ?? null,
summaryData: raw.SummaryData ?? null,
};
}, [entry]);
if (!entry) {
return (
<div className="report-container">
<Stack gap="md" align="center">
<Badge color="gray" variant="light">No Data</Badge>
<Text size="sm" c="dimmed">Run the tool to generate the report.</Text>
</Stack>
</div>
);
}
return (
<div className="report-container" ref={containerRef}>
<Stack gap="xl" align="center">
<div className="simulated-page">
<Stack gap="lg">
<Stack gap="xs">
<Text fw={700} size="xl" style={{ lineHeight: 1.3, wordBreak: 'break-word' }}>
{entry.fileName}
<Text component="span" fw={700}> - {t('getPdfInfo.summary.title', 'PDF Summary')}</Text>
</Text>
<Divider />
</Stack>
<SummarySection sections={sections} hideSectionTitle />
<KeyValueSection title={t('getPdfInfo.sections.metadata', 'Metadata')} anchorId="metadata" obj={sections.metadata} />
<KeyValueSection title={t('getPdfInfo.sections.formFields', 'Form Fields')} anchorId="formFields" obj={sections.formFields} />
<KeyValueSection title={t('getPdfInfo.sections.basicInfo', 'Basic Info')} anchorId="basicInfo" obj={sections.basicInfo} />
<KeyValueSection title={t('getPdfInfo.sections.documentInfo', 'Document Info')} anchorId="documentInfo" obj={sections.documentInfo} />
<KeyValueSection title={t('getPdfInfo.sections.compliance', 'Compliance')} anchorId="compliance" obj={sections.compliance} />
<KeyValueSection title={t('getPdfInfo.sections.encryption', 'Encryption')} anchorId="encryption" obj={sections.encryption} />
<KeyValueSection title={t('getPdfInfo.sections.permissions', 'Permissions')} anchorId="permissions" obj={sections.permissions} />
<TableOfContentsSection anchorId="toc" tocArray={sections.toc ?? []} />
<OtherSection anchorId="other" other={sections.other} />
<PerPageSection anchorId="perPage" perPage={sections.perPage} />
</Stack>
</div>
</Stack>
</div>
);
};
export default GetPdfInfoReportView;
@@ -0,0 +1,79 @@
import { useCallback, useMemo } from 'react';
import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
interface GetPdfInfoResultsProps {
operation: GetPdfInfoOperationHook;
isLoading: boolean;
errorMessage: string | null;
}
const findFileByExtension = (files: File[], extension: string) => {
return files.find((file) => file.name.toLowerCase().endsWith(extension));
};
const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoResultsProps) => {
const { t } = useTranslation();
const jsonFile = useMemo(() => findFileByExtension(operation.files, '.json'), [operation.files]);
const selectedFile = useMemo(() => jsonFile ?? null, [jsonFile]);
const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]);
const handleDownload = useCallback((file: File) => {
const blobUrl = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}, []);
if (isLoading && operation.results.length === 0) {
return (
<Group justify="center" gap="sm" py="md">
<Loader size="sm" />
<Text>{t('getPdfInfo.processing', 'Extracting information...')}</Text>
</Group>
);
}
if (!isLoading && operation.results.length === 0) {
return (
<Alert color="gray" variant="light" title={t('getPdfInfo.results', 'Results')}>
<Text size="sm">{t('getPdfInfo.noResults', 'Run the tool to generate a report.')}</Text>
</Alert>
);
}
return (
<Stack gap="md">
{/* No background post-processing once JSON is ready */}
{errorMessage && (
<Alert color="yellow" variant="light">
<Text size="sm">{errorMessage}</Text>
</Alert>
)}
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('getPdfInfo.downloads', 'Downloads')}
</Text>
<Button
color="blue"
onClick={() => selectedFile && handleDownload(selectedFile)}
disabled={!selectedFile}
fullWidth
>
{selectedDownloadLabel}
</Button>
</Stack>
</Stack>
);
};
export default GetPdfInfoResults;
@@ -0,0 +1,22 @@
import React from 'react';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList';
interface KeyValueSectionProps {
title: string;
anchorId: string;
obj?: Record<string, unknown> | null;
emptyLabel?: string;
}
const KeyValueSection: React.FC<KeyValueSectionProps> = ({ title, anchorId, obj, emptyLabel }) => {
return (
<SectionBlock title={title} anchorId={anchorId}>
<KeyValueList obj={obj} emptyLabel={emptyLabel} />
</SectionBlock>
);
};
export default KeyValueSection;
@@ -0,0 +1,84 @@
import React from 'react';
import { Accordion, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { PdfOtherInfo } from '@app/types/getPdfInfo';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
import ScrollableCodeBlock from '@app/components/tools/getPdfInfo/shared/ScrollableCodeBlock';
import { pdfInfoAccordionStyles } from '@app/components/tools/getPdfInfo/shared/accordionStyles';
interface OtherSectionProps {
anchorId: string;
other?: PdfOtherInfo | null;
}
const renderList = (arr: unknown[] | undefined, emptyText: string) => {
if (!arr || arr.length === 0) return <Text size="sm" c="dimmed">{emptyText}</Text>;
return (
<Stack gap={4}>
{arr.map((item, idx) => (
<Text key={idx} size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}>
{typeof item === 'string' ? item : JSON.stringify(item)}
</Text>
))}
</Stack>
);
};
const OtherSection: React.FC<OtherSectionProps> = ({ anchorId, other }) => {
const { t } = useTranslation();
const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
const structureTreeContent = Array.isArray(other?.StructureTree) && other.StructureTree.length > 0
? JSON.stringify(other.StructureTree, null, 2)
: null;
return (
<SectionBlock title={t('getPdfInfo.sections.other', 'Other')} anchorId={anchorId}>
<Stack gap="sm">
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.attachments', 'Attachments')}</Text>
{renderList(other?.Attachments, noneDetected)}
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.embeddedFiles', 'Embedded Files')}</Text>
{renderList(other?.EmbeddedFiles, noneDetected)}
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.javaScript', 'JavaScript')}</Text>
{renderList(other?.JavaScript, noneDetected)}
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.layers', 'Layers')}</Text>
{renderList(other?.Layers, noneDetected)}
</Stack>
<Accordion
variant="separated"
radius="md"
defaultValue=""
styles={pdfInfoAccordionStyles}
>
<Accordion.Item value="structureTree">
<Accordion.Control>
<Text fw={600} size="sm">{t('getPdfInfo.other.structureTree', 'StructureTree')}</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollableCodeBlock content={structureTreeContent} maxHeight="20rem" />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="xmp">
<Accordion.Control>
<Text fw={600} size="sm">{t('getPdfInfo.other.xmp', 'XMPMetadata')}</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollableCodeBlock content={other?.XMPMetadata} maxHeight="400px" />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</SectionBlock>
);
};
export default OtherSection;
@@ -0,0 +1,122 @@
import React from 'react';
import { Accordion, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { PdfPerPageInfo, PdfPageInfo, PdfFontInfo } from '@app/types/getPdfInfo';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList';
import { pdfInfoAccordionStyles } from '@app/components/tools/getPdfInfo/shared/accordionStyles';
interface PerPageSectionProps {
anchorId: string;
perPage?: PdfPerPageInfo | null;
}
const renderList = (arr: unknown[] | undefined, emptyText: string) => {
if (!arr || arr.length === 0) return <Text size="sm" c="dimmed">{emptyText}</Text>;
return (
<Stack gap={4}>
{arr.map((item, idx) => (
<Text key={idx} size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}>
{typeof item === 'string' ? item : JSON.stringify(item)}
</Text>
))}
</Stack>
);
};
const renderFontsList = (fonts: PdfFontInfo[] | undefined, emptyText: string) => {
if (!fonts || fonts.length === 0) return <Text size="sm" c="dimmed">{emptyText}</Text>;
return (
<Stack gap={4}>
{fonts.map((font, idx) => (
<Text key={idx} size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}>
{`${font.Name ?? 'Unknown'}${font.IsEmbedded ? ' (embedded)' : ''}`}
</Text>
))}
</Stack>
);
};
const PerPageSection: React.FC<PerPageSectionProps> = ({ anchorId, perPage }) => {
const { t } = useTranslation();
const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
const hasPages = perPage && Object.keys(perPage).length > 0;
return (
<SectionBlock title={t('getPdfInfo.sections.perPageInfo', 'Per Page Info')} anchorId={anchorId}>
{hasPages ? (
<Accordion
variant="separated"
radius="md"
defaultValue=""
styles={pdfInfoAccordionStyles}
>
{Object.entries(perPage).map(([pageLabel, pageInfo]: [string, PdfPageInfo]) => (
<Accordion.Item key={pageLabel} value={pageLabel}>
<Accordion.Control>
<Text fw={600} size="sm">{pageLabel}</Text>
</Accordion.Control>
<Accordion.Panel>
<div style={{ backgroundColor: 'var(--bg-raised)', color: 'var(--text-primary)', borderRadius: 8, padding: 12 }}>
<Stack gap="sm">
{pageInfo?.Size && (
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.size', 'Size')}</Text>
<KeyValueList obj={pageInfo.Size} />
</Stack>
)}
<KeyValueList obj={{
'Rotation': pageInfo?.Rotation,
'Page Orientation': pageInfo?.['Page Orientation'],
'MediaBox': pageInfo?.MediaBox,
'CropBox': pageInfo?.CropBox,
'BleedBox': pageInfo?.BleedBox,
'TrimBox': pageInfo?.TrimBox,
'ArtBox': pageInfo?.ArtBox,
'Text Characters Count': pageInfo?.['Text Characters Count'],
}} />
{pageInfo?.Annotations && (
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.annotations', 'Annotations')}</Text>
<KeyValueList obj={pageInfo.Annotations} />
</Stack>
)}
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.images', 'Images')}</Text>
{renderList(pageInfo?.Images, noneDetected)}
</Stack>
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.links', 'Links')}</Text>
{renderList(pageInfo?.Links, noneDetected)}
</Stack>
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.fonts', 'Fonts')}</Text>
{renderFontsList(pageInfo?.Fonts, noneDetected)}
</Stack>
{pageInfo?.XObjectCounts && (
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.xobjects', 'XObject Counts')}</Text>
<KeyValueList obj={pageInfo.XObjectCounts} />
</Stack>
)}
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.multimedia', 'Multimedia')}</Text>
{renderList(pageInfo?.Multimedia, noneDetected)}
</Stack>
</Stack>
</div>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
) : (
<Text size="sm" c="dimmed">{noneDetected}</Text>
)}
</SectionBlock>
);
};
export default PerPageSection;
@@ -0,0 +1,148 @@
import React, { useMemo } from 'react';
import { Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { ParsedPdfSections, PdfFontInfo } from '@app/types/getPdfInfo';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList';
interface SummarySectionProps {
sections: ParsedPdfSections;
hideSectionTitle?: boolean;
}
const SummarySection: React.FC<SummarySectionProps> = ({ sections, hideSectionTitle = false }) => {
const { t } = useTranslation();
const summaryBlocks = useMemo(() => {
const basic = sections.basicInfo ?? {};
const docInfo = sections.documentInfo ?? {};
const metadata = sections.metadata ?? {};
const encryption = sections.encryption ?? {};
const permissions = sections.permissions ?? {};
const summary = sections.summaryData ?? {};
const other = sections.other ?? {};
const perPage = sections.perPage ?? {};
const pages = basic['Number of pages'];
const fileSizeBytes = basic.FileSizeInBytes;
const pdfVersion = docInfo['PDF version'];
const language = basic.Language;
const basicInformation: Record<string, unknown> = {
[t('getPdfInfo.summary.pages', 'Pages')]: pages,
[t('getPdfInfo.summary.fileSize', 'File Size')]: typeof fileSizeBytes === 'number' ? `${(fileSizeBytes / 1024).toFixed(2)} KB` : fileSizeBytes,
[t('getPdfInfo.summary.pdfVersion', 'PDF Version')]: pdfVersion,
[t('getPdfInfo.summary.language', 'Language')]: language,
};
const documentInformation: Record<string, unknown> = {
[t('getPdfInfo.summary.title', 'Title')]: metadata.Title,
[t('getPdfInfo.summary.author', 'Author')]: metadata.Author,
[t('getPdfInfo.summary.created', 'Created')]: metadata.CreationDate,
[t('getPdfInfo.summary.modified', 'Modified')]: metadata.ModificationDate,
};
const securityStatusText = encryption.IsEncrypted
? t('getPdfInfo.summary.security.encrypted', 'Encrypted PDF - Password protection present')
: t('getPdfInfo.summary.security.unencrypted', 'Unencrypted PDF - No password protection');
const restrictedCount = summary.restrictedPermissionsCount ?? 0;
const permissionsAllAllowed = Object.values(permissions).every((v) => v === 'Allowed');
const permSummary = permissionsAllAllowed
? t('getPdfInfo.summary.permsAll', 'All Permissions Allowed')
: restrictedCount > 0
? t('getPdfInfo.summary.permsRestricted', '{{count}} restrictions', { count: restrictedCount })
: t('getPdfInfo.summary.permsMixed', 'Some permissions restricted');
const complianceText = sections.compliance && Object.values(sections.compliance).some(Boolean)
? t('getPdfInfo.summary.hasCompliance', 'Has compliance standards')
: t('getPdfInfo.summary.noCompliance', 'No Compliance Standards');
// Helper to get first page data
const firstPage = perPage['Page 1'];
const firstPageFonts: PdfFontInfo[] = firstPage?.Fonts ?? [];
const technical: Record<string, unknown> = {
[t('getPdfInfo.summary.tech.images', 'Images')]: (() => {
const total = basic.TotalImages;
if (typeof total === 'number') return total === 0 ? 'None' : `${total}`;
return 'None';
})(),
[t('getPdfInfo.summary.tech.fonts', 'Fonts')]: (() => {
if (firstPageFonts.length === 0) return 'None';
const embedded = firstPageFonts.filter((f) => f.IsEmbedded).length;
return `${firstPageFonts.length} (${embedded} embedded)`;
})(),
[t('getPdfInfo.summary.tech.formFields', 'Form Fields')]: sections.formFields && Object.keys(sections.formFields).length > 0 ? Object.keys(sections.formFields).length : 'None',
[t('getPdfInfo.summary.tech.embeddedFiles', 'Embedded Files')]: other.EmbeddedFiles?.length ?? 'None',
[t('getPdfInfo.summary.tech.javaScript', 'JavaScript')]: other.JavaScript?.length ?? 'None',
[t('getPdfInfo.summary.tech.layers', 'Layers')]: other.Layers?.length ?? 'None',
[t('getPdfInfo.summary.tech.bookmarks', 'Bookmarks')]: sections.toc?.length ?? 'None',
[t('getPdfInfo.summary.tech.multimedia', 'Multimedia')]: firstPage?.Multimedia?.length ?? 'None',
};
const overview = (() => {
const tTitle = metadata.Title ? `"${metadata.Title}"` : t('getPdfInfo.summary.overview.untitled', 'an untitled document');
const author = metadata.Author || t('getPdfInfo.summary.overview.unknown', 'Unknown Author');
const pagesCount = typeof pages === 'number' ? pages : '?';
const version = pdfVersion ?? '?';
return t('getPdfInfo.summary.overview.text', 'This is a {{pages}}-page PDF titled {{title}} created by {{author}} (PDF version {{version}}).', {
pages: pagesCount,
title: tTitle,
author,
version,
});
})();
return {
basicInformation,
documentInformation,
securityStatusText,
permSummary,
complianceText,
technical,
overview,
};
}, [sections, t]);
const content = (
<Stack gap="md">
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.basic', 'Basic Information')}</Text>
<KeyValueList obj={summaryBlocks.basicInformation} />
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.documentInfo', 'Document Information')}</Text>
<KeyValueList obj={summaryBlocks.documentInformation} />
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.securityTitle', 'Security Status')}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.securityStatusText}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.permSummary}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.complianceText}</Text>
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.technical', 'Technical')}</Text>
<KeyValueList obj={summaryBlocks.technical} />
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.overviewTitle', 'PDF Overview')}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.overview}</Text>
</Stack>
</Stack>
);
if (hideSectionTitle) {
return <div id="summary">{content}</div>;
}
return (
<SectionBlock title={t('getPdfInfo.summary.title', 'PDF Summary')} anchorId="summary">
{content}
</SectionBlock>
);
};
export default SummarySection;
@@ -0,0 +1,35 @@
import React from 'react';
import { Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { PdfTocEntry } from '@app/types/getPdfInfo';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
interface TableOfContentsSectionProps {
anchorId: string;
tocArray: PdfTocEntry[];
}
const TableOfContentsSection: React.FC<TableOfContentsSectionProps> = ({ anchorId, tocArray }) => {
const { t } = useTranslation();
const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
return (
<SectionBlock title={t('getPdfInfo.sections.tableOfContents', 'Table of Contents')} anchorId={anchorId}>
{!tocArray || tocArray.length === 0 ? (
<Text size="sm" c="dimmed">{noneDetected}</Text>
) : (
<Stack gap={4}>
{tocArray.map((item, idx) => (
<Text key={idx} size="sm" c="dimmed">
{typeof item === 'string' ? item : JSON.stringify(item)}
</Text>
))}
</Stack>
)}
</SectionBlock>
);
};
export default TableOfContentsSection;
@@ -0,0 +1,29 @@
import React from 'react';
import { Group, Stack, Text } from '@mantine/core';
interface KeyValueListProps {
obj?: Record<string, unknown> | null;
emptyLabel?: string;
}
const KeyValueList: React.FC<KeyValueListProps> = ({ obj, emptyLabel }) => {
if (!obj || Object.keys(obj).length === 0) {
return <Text size="sm" c="dimmed">{emptyLabel ?? 'None detected'}</Text>;
}
return (
<Stack gap={6}>
{Object.entries(obj).map(([k, v]) => (
<Group key={k} wrap="nowrap" align="flex-start" style={{ width: '100%' }}>
<Text size="sm" style={{ minWidth: 180, maxWidth: 180, flexShrink: 0 }}>{k}</Text>
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word', flex: 1 }}>
{v == null ? '' : String(v)}
</Text>
</Group>
))}
</Stack>
);
};
export default KeyValueList;
@@ -0,0 +1,47 @@
import React from 'react';
import { Code, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ScrollableCodeBlockProps {
content: string | null | undefined;
maxHeight?: string;
emptyMessage?: string;
}
/**
* A reusable scrollable code block component with consistent styling.
* Used for displaying large text content like XMP metadata or structure trees.
*/
const ScrollableCodeBlock: React.FC<ScrollableCodeBlockProps> = ({
content,
maxHeight = '400px',
emptyMessage,
}) => {
const { t } = useTranslation();
if (!content) {
return (
<Text size="sm" c="dimmed">
{emptyMessage ?? t('getPdfInfo.noneDetected', 'None detected')}
</Text>
);
}
return (
<Code
block
style={{
whiteSpace: 'pre-wrap',
backgroundColor: 'var(--bg-raised)',
color: 'var(--text-primary)',
maxHeight,
overflowY: 'auto',
}}
>
{content}
</Code>
);
};
export default ScrollableCodeBlock;
@@ -0,0 +1,22 @@
import React from 'react';
import { Stack, Text, Divider } from '@mantine/core';
interface SectionBlockProps {
title: string;
anchorId: string;
children: React.ReactNode;
}
const SectionBlock: React.FC<SectionBlockProps> = ({ title, anchorId, children }) => {
return (
<Stack gap="sm" id={anchorId}>
<Text fw={700} size="lg">{title}</Text>
<Divider />
{children}
</Stack>
);
};
export default SectionBlock;
@@ -0,0 +1,14 @@
import type { AccordionStylesNames } from '@mantine/core';
import type { CSSProperties } from 'react';
type AccordionStyles = Partial<Record<AccordionStylesNames, CSSProperties>>;
export const pdfInfoAccordionStyles: AccordionStyles = {
item: {
backgroundColor: 'var(--accordion-item-bg)',
},
control: {
backgroundColor: 'transparent',
},
};
@@ -134,7 +134,7 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
textDecoration: 'underline',
textAlign: 'center'
}}
onClick={() => window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')}
onClick={() => window.open('https://docs.stirlingpdf.com/Configuration/OCR', '_blank')}
>
{t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')}
</Text>
@@ -158,4 +158,4 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
);
};
export default LanguagePicker;
export default LanguagePicker;
@@ -21,6 +21,7 @@ import {
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined';
@@ -32,9 +33,12 @@ import CloseIcon from '@mui/icons-material/Close';
import MergeTypeIcon from '@mui/icons-material/MergeType';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import UploadFileIcon from '@mui/icons-material/UploadFileOutlined';
import SaveIcon from '@mui/icons-material/SaveOutlined';
import { Rnd } from 'react-rnd';
import NavigationWarningModal from '@core/components/shared/NavigationWarningModal';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { useFileContext } from '@app/contexts/FileContext';
import {
PdfTextEditorViewData,
PdfJsonFont,
@@ -313,6 +317,7 @@ type GroupingMode = 'auto' | 'paragraph' | 'singleLine';
const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
const { t } = useTranslation();
const { activeFiles } = useFileContext();
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [activeImageId, setActiveImageId] = useState<string | null>(null);
@@ -375,6 +380,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
fileName,
errorMessage,
isGeneratingPdf,
isSavingToWorkbench,
isConverting,
conversionProgress,
hasChanges,
@@ -389,11 +395,12 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
onReset,
onDownloadJson,
onGeneratePdf,
onGeneratePdfForNavigation,
onSaveToWorkbench,
onForceSingleTextElementChange,
onGroupingModeChange,
onMergeGroups,
onUngroupGroup,
onLoadFile,
} = data;
// Define derived variables immediately after props destructuring, before any hooks
@@ -1430,7 +1437,8 @@ const selectionToolbarPosition = useMemo(() => {
height: '100%',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 320px',
alignItems: 'start',
gridTemplateRows: '1fr',
alignItems: hasDocument ? 'start' : 'stretch',
gap: '1.5rem',
}}
>
@@ -1486,6 +1494,17 @@ const selectionToolbarPosition = useMemo(() => {
>
{t('pdfTextEditor.actions.generatePdf', 'Generate PDF')}
</Button>
<Button
variant="filled"
color="green"
leftSection={<SaveIcon fontSize="small" />}
onClick={onSaveToWorkbench}
loading={isSavingToWorkbench}
disabled={!hasDocument || !hasChanges || isConverting}
fullWidth
>
{t('pdfTextEditor.actions.saveChanges', 'Save Changes')}
</Button>
</Stack>
{fileName && (
@@ -1639,17 +1658,45 @@ const selectionToolbarPosition = useMemo(() => {
)}
{!hasDocument && !isConverting && (
<Card withBorder radius="md" padding="xl" style={{ gridColumn: '1 / 2', gridRow: 1 }}>
<Stack align="center" gap="md">
<DescriptionIcon sx={{ fontSize: 48 }} />
<Text size="lg" fw={600}>
{t('pdfTextEditor.empty.title', 'No document loaded')}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
{t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')}
</Text>
</Stack>
</Card>
<Stack
align="center"
justify="center"
style={{ gridColumn: '1 / 2', gridRow: 1, height: '100%' }}
>
<Dropzone
onDrop={(files) => {
if (files.length > 0) {
onLoadFile(files[0]);
}
}}
accept={['application/pdf', 'application/json']}
maxFiles={1}
style={{
width: '100%',
maxWidth: 480,
minHeight: 200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed var(--mantine-color-gray-4)',
borderRadius: 'var(--mantine-radius-lg)',
cursor: 'pointer',
transition: 'border-color 150ms ease, background-color 150ms ease',
}}
>
<Stack align="center" gap="md" style={{ pointerEvents: 'none' }}>
<UploadFileIcon sx={{ fontSize: 48, color: 'var(--mantine-color-blue-5)' }} />
<Text size="lg" fw={600}>
{t('pdfTextEditor.empty.title', 'No document loaded')}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
{activeFiles.length > 0
? t('pdfTextEditor.empty.dropzoneWithFiles', 'Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse')
: t('pdfTextEditor.empty.dropzone', 'Drag and drop a PDF or JSON file here, or click to browse')}
</Text>
</Stack>
</Dropzone>
</Stack>
)}
{isConverting && (
@@ -1683,7 +1730,7 @@ const selectionToolbarPosition = useMemo(() => {
</Card>
)}
{hasDocument && (
{hasDocument && !isConverting && (
<Stack
gap="lg"
className="flex-1"
@@ -2147,14 +2194,20 @@ const selectionToolbarPosition = useMemo(() => {
// Determine text wrapping behavior based on whether text has been changed
const hasChanges = changed;
const widthExtended = resolvedWidth - baseWidth > 0.5;
const enableWrap = isParagraphLayout || widthExtended || isEditing || hasChanges;
// Only enable wrapping if:
// 1. It's paragraph layout (multi-line groups should wrap)
// 2. Width was manually extended (user explicitly made space for wrapping)
// 3. Has changes AND was already wrapping (preserve existing wrap state)
// DO NOT enable wrapping just because isEditing - text should only wrap when it actually overflows
const wasWrapping = isParagraphLayout || widthExtended;
const enableWrap = wasWrapping || (hasChanges && wasWrapping);
const whiteSpace = enableWrap ? 'pre-wrap' : 'pre';
const wordBreak = enableWrap ? 'break-word' : 'normal';
const overflowWrap = enableWrap ? 'break-word' : 'normal';
// For paragraph mode, allow height to grow to accommodate lines without wrapping
// For single-line mode, maintain fixed height based on PDF bounds
const useFlexibleHeight = isEditing || enableWrap || (isParagraphLayout && lineCount > 1);
const useFlexibleHeight = enableWrap || (isParagraphLayout && lineCount > 1);
// The renderGroupContainer wrapper adds 4px horizontal padding (2px left + 2px right)
// We need to add this to the container width to compensate, so the inner content
@@ -2438,7 +2491,7 @@ const selectionToolbarPosition = useMemo(() => {
{/* Navigation Warning Modal */}
<NavigationWarningModal
onApplyAndContinue={onGeneratePdfForNavigation}
onApplyAndContinue={onSaveToWorkbench}
/>
</Stack>
);
@@ -87,6 +87,7 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
{config.steps.map((stepConfig) =>
steps.create(stepConfig.title, {
isVisible: stepConfig.isVisible,
isCollapsed: stepConfig.isCollapsed,
onCollapsedClick: stepConfig.onCollapsedClick,
tooltip: stepConfig.tooltip
}, stepConfig.content)
@@ -44,15 +44,15 @@
.simulated-page {
width: min(820px, 100%);
min-height: 1040px;
background-color: rgb(var(--pdf-light-simulated-page-bg)) !important;
box-shadow: 0 12px 32px rgba(var(--pdf-light-simulated-page-text), 0.12) !important;
background-color: var(--bg-raised) !important;
box-shadow: 0 12px 32px var(--shadow-color) !important;
border-radius: 12px !important;
padding: 48px 56px !important;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
color: rgb(var(--pdf-light-simulated-page-text)) !important;
color: var(--text-primary) !important;
}
/* Container for the interactive report view */
@@ -67,12 +67,12 @@
/* Keep field blocks stable colors across themes */
.field-value {
border: 1px solid rgb(var(--pdf-light-box-border)) !important;
background-color: rgb(var(--pdf-light-box-bg)) !important;
border: 1px solid var(--border-default) !important;
background-color: var(--bg-raised) !important;
}
.field-container {
color: rgb(var(--pdf-light-simulated-page-text)) !important;
color: var(--text-primary) !important;
}
/* Thumbnail preview styles */
@@ -103,3 +103,28 @@
color: rgb(var(--pdf-light-text-muted));
background: linear-gradient(145deg, var(--mantine-color-gray-1) 0%, var(--mantine-color-gray-0) 100%);
}
/* Flash highlight animation for section navigation */
@keyframes section-flash {
0% {
background-color: rgba(255, 235, 59, 0);
box-shadow: none;
}
20% {
background-color: rgba(255, 235, 59, 0.35);
box-shadow: 0 0 20px rgba(255, 235, 59, 0.5);
}
50% {
background-color: rgba(255, 235, 59, 0.25);
box-shadow: 0 0 15px rgba(255, 235, 59, 0.4);
}
100% {
background-color: rgba(255, 235, 59, 0);
box-shadow: none;
}
}
.section-flash-highlight {
animation: section-flash 1.5s ease-out;
border-radius: 8px;
}
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useReducer, useCallback } from 'react';
import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
import { WorkbenchType, getDefaultWorkbench } from '@app/types/workbench';
import { ToolId, isValidToolId } from '@app/types/toolId';
import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
@@ -110,8 +110,8 @@ export const NavigationProvider: React.FC<{
const { allTools: toolRegistry } = useToolRegistry();
const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null);
const actions: NavigationContextActions = {
setWorkbench: useCallback((workbench: WorkbenchType) => {
// Memoize individual callbacks
const setWorkbench = useCallback((workbench: WorkbenchType) => {
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
console.log('[NavigationContext] setWorkbench:', {
@@ -152,13 +152,13 @@ export const NavigationProvider: React.FC<{
} else {
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
}
}, [state.workbench, state.hasUnsavedChanges]),
}, [state.workbench, state.hasUnsavedChanges]);
setSelectedTool: useCallback((toolId: ToolId | null) => {
const setSelectedTool = useCallback((toolId: ToolId | null) => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } });
}, []),
}, []);
setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
const setToolAndWorkbench = useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
@@ -177,25 +177,25 @@ export const NavigationProvider: React.FC<{
} else {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
}
}, [state.workbench, state.hasUnsavedChanges]),
}, [state.workbench, state.hasUnsavedChanges]);
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
}, []),
}, []);
registerUnsavedChangesChecker: useCallback((checker: () => boolean) => {
const registerUnsavedChangesChecker = useCallback((checker: () => boolean) => {
unsavedChangesCheckerRef.current = checker;
}, []),
}, []);
unregisterUnsavedChangesChecker: useCallback(() => {
const unregisterUnsavedChangesChecker = useCallback(() => {
unsavedChangesCheckerRef.current = null;
}, []),
}, []);
showNavigationWarning: useCallback((show: boolean) => {
const showNavigationWarning = useCallback((show: boolean) => {
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
}, []),
}, []);
requestNavigation: useCallback((navigationFn: () => void) => {
const requestNavigation = useCallback((navigationFn: () => void) => {
if (!state.hasUnsavedChanges) {
navigationFn();
return;
@@ -203,9 +203,9 @@ export const NavigationProvider: React.FC<{
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
}, [state.hasUnsavedChanges]),
}, [state.hasUnsavedChanges]);
confirmNavigation: useCallback(() => {
const confirmNavigation = useCallback(() => {
console.log('[NavigationContext] confirmNavigation called', {
hasPendingNav: !!state.pendingNavigation,
currentWorkbench: state.workbench,
@@ -218,18 +218,18 @@ export const NavigationProvider: React.FC<{
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
console.log('[NavigationContext] confirmNavigation completed');
}, [state.pendingNavigation, state.workbench, state.selectedTool]),
}, [state.pendingNavigation, state.workbench, state.selectedTool]);
cancelNavigation: useCallback(() => {
const cancelNavigation = useCallback(() => {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, []),
}, []);
clearToolSelection: useCallback(() => {
const clearToolSelection = useCallback(() => {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
}, []),
}, []);
handleToolSelect: useCallback((toolId: string) => {
const handleToolSelect = useCallback((toolId: string) => {
if (toolId === 'allTools') {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
return;
@@ -245,11 +245,40 @@ export const NavigationProvider: React.FC<{
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
// Validate toolId and convert to ToolId type
const validToolId = isValidToolId(toolId) ? toolId : null;
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
}, [toolRegistry])
};
// Validate toolId and convert to ToolId type
const validToolId = isValidToolId(toolId) ? toolId : null;
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
}, [toolRegistry]);
// Memoize the actions object to prevent unnecessary context updates
// This is critical to avoid infinite loops when effects depend on actions
const actions: NavigationContextActions = useMemo(() => ({
setWorkbench,
setSelectedTool,
setToolAndWorkbench,
setHasUnsavedChanges,
registerUnsavedChangesChecker,
unregisterUnsavedChangesChecker,
showNavigationWarning,
requestNavigation,
confirmNavigation,
cancelNavigation,
clearToolSelection,
handleToolSelect,
}), [
setWorkbench,
setSelectedTool,
setToolAndWorkbench,
setHasUnsavedChanges,
registerUnsavedChangesChecker,
unregisterUnsavedChangesChecker,
showNavigationWarning,
requestNavigation,
confirmNavigation,
cancelNavigation,
clearToolSelection,
handleToolSelect,
]);
const stateValue: NavigationContextStateValue = {
workbench: state.workbench,
@@ -259,9 +288,10 @@ export const NavigationProvider: React.FC<{
showNavigationWarning: state.showNavigationWarning
};
const actionsValue: NavigationContextActionsValue = {
// Also memoize the context value to prevent unnecessary re-renders
const actionsValue: NavigationContextActionsValue = useMemo(() => ({
actions
};
}), [actions]);
return (
<NavigationStateContext.Provider value={stateValue}>
@@ -224,11 +224,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
return;
}
if (navigationState.pendingNavigation || navigationState.showNavigationWarning) {
return;
}
const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
if (!currentCustomView || currentCustomView.data == null) {
actions.setWorkbench(getDefaultWorkbench());
}
}, [actions, customWorkbenchViews, navigationState.workbench]);
}, [actions, customWorkbenchViews, navigationState.workbench, navigationState.pendingNavigation, navigationState.showNavigationWarning]);
// Persisted via PreferencesContext; no direct localStorage writes needed here
@@ -27,6 +27,7 @@ import AdjustContrastSingleStepSettings from "@app/components/tools/adjustContra
import { adjustContrastOperationConfig } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation";
import { getSynonyms } from "@app/utils/toolSynonyms";
import { useProprietaryToolRegistry } from "@app/data/useProprietaryToolRegistry";
import GetPdfInfo from "@app/tools/GetPdfInfo";
import AddWatermark from "@app/tools/AddWatermark";
import AddStamp from "@app/tools/AddStamp";
import AddAttachments from "@app/tools/AddAttachments";
@@ -43,6 +44,7 @@ import CertSign from "@app/tools/CertSign";
import BookletImposition from "@app/tools/BookletImposition";
import Flatten from "@app/tools/Flatten";
import Rotate from "@app/tools/Rotate";
import PdfTextEditor from "@app/tools/pdfTextEditor/PdfTextEditor";
import ChangeMetadata from "@app/tools/ChangeMetadata";
import Crop from "@app/tools/Crop";
import Sign from "@app/tools/Sign";
@@ -150,6 +152,23 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
// Proprietary tools (if any)
...proprietaryTools,
// Recommended Tools in order
pdfTextEditor: {
icon: <LocalIcon icon="edit-square-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.pdfTextEditor.title", "PDF Text Editor"),
component: PdfTextEditor,
description: t(
"home.pdfTextEditor.desc",
"Review and edit text and images in PDFs with grouped text editing and PDF regeneration"
),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: 1,
endpoints: ["text-editor-pdf"],
synonyms: getSynonyms(t, "pdfTextEditor"),
supportsAutomate: false,
automationSettings: null,
versionStatus: "alpha",
},
multiTool: {
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.multiTool.title", "Multi-Tool"),
@@ -323,14 +342,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
getPdfInfo: {
icon: <LocalIcon icon="fact-check-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
component: null,
component: GetPdfInfo,
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
endpoints: ["get-info-on-pdf"],
synonyms: getSynonyms(t, "getPdfInfo"),
supportsAutomate: false,
automationSettings: null
automationSettings: null,
maxFiles: 1,
},
validateSignature: {
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { ToolType, useToolOperation, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { AdjustContrastParameters, defaultParameters } from '@app/hooks/tools/adjustContrast/useAdjustContrastParameters';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { applyAdjustmentsToCanvas } from '@app/components/tools/adjustContrast/utils';
@@ -46,7 +46,7 @@ async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParamet
return out;
}
async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise<File[]> {
async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise<CustomProcessorResult> {
// Limit concurrency to avoid exhausting memory/CPU while still getting speedups
// Heuristic: use up to 4 workers on capable machines, otherwise 2-3
let CONCURRENCY_LIMIT = 2;
@@ -72,7 +72,12 @@ async function processPdfClientSide(params: AdjustContrastParameters, files: Fil
return results;
};
return mapWithConcurrency(files, CONCURRENCY_LIMIT, (file) => buildAdjustedPdfForFile(file, params));
const processedFiles = await mapWithConcurrency(files, CONCURRENCY_LIMIT, (file) => buildAdjustedPdfForFile(file, params));
return {
files: processedFiles,
consumedAllInputs: false,
};
}
export const adjustContrastOperationConfig = {
@@ -36,7 +36,10 @@ export function useAutomateOperation() {
);
console.log(`✅ Automation completed, returning ${finalResults.length} files`);
return finalResults;
return {
files: finalResults,
consumedAllInputs: false,
};
}, [toolRegistry]);
return useToolOperation<AutomateParameters>({
@@ -3,8 +3,8 @@ import apiClient from '@app/services/apiClient';
import { useTranslation } from 'react-i18next';
import { ConvertParameters, defaultParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { getEndpointUrl, isImageFormat, isWebFormat } from '@app/utils/convertUtils';
import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { getEndpointUrl, isImageFormat, isWebFormat, isOfficeFormat } from '@app/utils/convertUtils';
// Static function that can be used by both the hook and automation executor
export const shouldProcessFilesSeparately = (
@@ -21,6 +21,10 @@ export const shouldProcessFilesSeparately = (
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') ||
// PDF to text-like formats should be one output per input
(parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) ||
// PDF to office format conversions (each PDF should generate its own office file)
(parameters.fromExtension === 'pdf' && isOfficeFormat(parameters.toExtension)) ||
// Office files to PDF conversions (each file should be processed separately via LibreOffice)
(isOfficeFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
// Web files to PDF conversions (each web file should generate its own PDF)
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
parameters.toExtension === 'pdf') ||
@@ -98,7 +102,7 @@ export const createFileFromResponse = (
export const convertProcessor = async (
parameters: ConvertParameters,
selectedFiles: File[]
): Promise<File[]> => {
): Promise<CustomProcessorResult> => {
const processedFiles: File[] = [];
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
@@ -107,7 +111,9 @@ export const convertProcessor = async (
}
// Convert-specific routing logic: decide batch vs individual processing
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
const isSeparateProcessing = shouldProcessFilesSeparately(selectedFiles, parameters);
if (isSeparateProcessing) {
// Individual processing for complex cases (PDF→image, smart detection, etc.)
for (const file of selectedFiles) {
try {
@@ -134,7 +140,14 @@ export const convertProcessor = async (
processedFiles.push(convertedFile);
}
return processedFiles;
// When batch processing multiple files into one output (e.g., 3 images → 1 PDF),
// mark all inputs as consumed even though there's only 1 output file
const isCombiningMultiple = !isSeparateProcessing && selectedFiles.length > 1;
return {
files: processedFiles,
consumedAllInputs: isCombiningMultiple,
};
};
// Static configuration object
@@ -151,7 +164,7 @@ export const useConvertOperation = () => {
const customConvertProcessor = useCallback(async (
parameters: ConvertParameters,
selectedFiles: File[]
): Promise<File[]> => {
): Promise<CustomProcessorResult> => {
return convertProcessor(parameters, selectedFiles);
}, []);
@@ -1,6 +1,6 @@
import apiClient from '@app/services/apiClient';
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { ToolType, useToolOperation, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ExtractPagesParameters, defaultParameters } from '@app/hooks/tools/extractPages/useExtractPagesParameters';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
@@ -23,7 +23,7 @@ async function resolveSelectionToCsv(expression: string, file: File): Promise<st
export const extractPagesOperationConfig = {
toolType: ToolType.custom,
operationType: 'extractPages',
customProcessor: async (parameters: ExtractPagesParameters, files: File[]): Promise<File[]> => {
customProcessor: async (parameters: ExtractPagesParameters, files: File[]): Promise<CustomProcessorResult> => {
const outputs: File[] = [];
for (const file of files) {
@@ -43,7 +43,10 @@ export const extractPagesOperationConfig = {
outputs.push(outFile);
}
return outputs;
return {
files: outputs,
consumedAllInputs: false,
};
},
defaultParameters,
} as const;
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { useFileContext } from '@app/contexts/file/fileHooks';
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
import type { StirlingFile } from '@app/types/fileContext';
import { extractErrorMessage } from '@app/utils/toolErrorHandler';
import {
PdfInfoReportEntry,
INFO_JSON_FILENAME,
} from '@app/types/getPdfInfo';
import type { GetPdfInfoParameters } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoParameters';
export interface GetPdfInfoOperationHook extends ToolOperationHook<GetPdfInfoParameters> {
results: PdfInfoReportEntry[];
}
export const useGetPdfInfoOperation = (): GetPdfInfoOperationHook => {
const { t } = useTranslation();
const { selectors } = useFileContext();
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [results, setResults] = useState<PdfInfoReportEntry[]>([]);
const cancelRequested = useRef(false);
const previousUrl = useRef<string | null>(null);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
cancelRequested.current = false;
setResults([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
setStatus('');
setErrorMessage(null);
}, [cleanupDownloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const executeOperation = useCallback(
async (_params: GetPdfInfoParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t('noFileSelected', 'No files selected'));
return;
}
cancelRequested.current = false;
setIsLoading(true);
setStatus(t('getPdfInfo.processing', 'Extracting information...'));
setErrorMessage(null);
setResults([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
try {
const aggregated: PdfInfoReportEntry[] = [];
const generatedAt = Date.now();
for (const file of selectedFiles) {
if (cancelRequested.current) break;
const formData = new FormData();
formData.append('fileInput', file);
try {
const response = await apiClient.post('/api/v1/security/get-info-on-pdf', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const stub = selectors.getStirlingFileStub(file.fileId);
const entry: PdfInfoReportEntry = {
fileId: file.fileId,
fileName: file.name,
fileSize: file.size ?? null,
lastModified: file.lastModified ?? null,
thumbnailUrl: stub?.thumbnailUrl ?? null,
data: response.data ?? {},
error: null,
summaryGeneratedAt: generatedAt,
};
aggregated.push(entry);
} catch (error) {
const stub = selectors.getStirlingFileStub(file.fileId);
aggregated.push({
fileId: file.fileId,
fileName: file.name,
fileSize: file.size ?? null,
lastModified: file.lastModified ?? null,
thumbnailUrl: stub?.thumbnailUrl ?? null,
data: {},
error: extractErrorMessage(error),
summaryGeneratedAt: generatedAt,
});
}
}
if (!cancelRequested.current) {
setResults(aggregated);
if (aggregated.length > 0) {
// Build V1-compatible JSON: use backend payloads directly.
const payloads = aggregated
.filter((e) => !e.error)
.map((e) => e.data);
const content = payloads.length === 1 ? payloads[0] : payloads;
const json = JSON.stringify(content, null, 2);
const resultFile = new File([json], INFO_JSON_FILENAME, { type: 'application/json' });
setFiles([resultFile]);
}
const anyError = aggregated.some((item) => item.error);
if (anyError) {
setErrorMessage(t('getPdfInfo.error.partial', 'Some files could not be processed.'));
}
setStatus(t('getPdfInfo.status.complete', 'Extraction complete'));
}
} catch (e) {
console.error('[getPdfInfo] unexpected failure', e);
setErrorMessage(t('getPdfInfo.error.unexpected', 'Unexpected error during extraction.'));
} finally {
setIsLoading(false);
}
},
[cleanupDownloadUrl, selectors, t]
);
const cancelOperation = useCallback(() => {
if (isLoading) {
cancelRequested.current = true;
setIsLoading(false);
setStatus(t('operationCancelled', 'Operation cancelled'));
}
}, [isLoading, t]);
const undoOperation = useCallback(async () => {
resetResults();
}, [resetResults]);
useEffect(() => {
return () => {
cleanupDownloadUrl();
};
}, [cleanupDownloadUrl]);
return useMemo<GetPdfInfoOperationHook>(
() => ({
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
results,
}),
[
cancelOperation,
clearError,
downloadFilename,
downloadUrl,
errorMessage,
executeOperation,
files,
isLoading,
resetResults,
results,
status,
]
);
};
@@ -0,0 +1,19 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface GetPdfInfoParameters extends BaseParameters {
// No parameters needed
}
export const defaultParameters: GetPdfInfoParameters = {};
export type GetPdfInfoParametersHook = BaseParametersHook<GetPdfInfoParameters>;
export const useGetPdfInfoParameters = (): GetPdfInfoParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'get-info-on-pdf',
});
};
@@ -1,10 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemoveAnnotationsParameters, defaultParameters } from '@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters';
import { PDFDocument, PDFName, PDFRef, PDFDict } from 'pdf-lib';
// Client-side PDF processing using PDF-lib
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<File[]> => {
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<CustomProcessorResult> => {
const processedFiles: File[] = [];
for (const file of files) {
@@ -75,7 +75,10 @@ const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParamete
}
}
return processedFiles;
return {
files: processedFiles,
consumedAllInputs: false,
};
};
// Static configuration object
@@ -47,6 +47,10 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
const { selectedFiles } = useFileSelection();
const previousFileCount = useRef(selectedFiles.length);
// Prevent reset immediately after operation completes (when consumeFiles auto-selects outputs)
const skipNextSelectionResetRef = useRef(false);
const previousSelectionRef = useRef<string>('');
// Tool-specific hooks
const params = useParams();
const operation = useOperation();
@@ -54,19 +58,45 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
// Endpoint validation using parameters hook
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(params.getEndpointName());
// Standard computed state - defined early so it's available in useEffects
const hasFiles = selectedFiles.length >= minFiles;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
// Reset results when parameters change
useEffect(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [params.parameters]);
// Reset results when selected files change
// When operation completes, flag the next selection change to skip reset
// (consumeFiles auto-selects outputs immediately after processing)
useEffect(() => {
if (selectedFiles.length > 0) {
operation.resetResults();
onPreviewFile?.(null);
if (hasResults) {
skipNextSelectionResetRef.current = true;
}
}, [selectedFiles.length]);
}, [hasResults]);
// Reset results when user manually changes file selection
useEffect(() => {
if (selectedFiles.length === 0) return;
const currentSelection = selectedFiles.map(f => f.fileId).sort().join(',');
if (currentSelection === previousSelectionRef.current) return; // No change
// Skip reset if this is the auto-selection after operation completed
if (skipNextSelectionResetRef.current) {
skipNextSelectionResetRef.current = false;
previousSelectionRef.current = currentSelection;
return;
}
// User manually selected different files - reset results
previousSelectionRef.current = currentSelection;
operation.resetResults();
onPreviewFile?.(null);
}, [selectedFiles]);
// Reset parameters when transitioning from 0 files to at least 1 file
useEffect(() => {
@@ -101,6 +131,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
}, [onPreviewFile, toolName]);
const handleSettingsReset = useCallback(() => {
skipNextSelectionResetRef.current = false;
operation.resetResults();
onPreviewFile?.(null);
}, [operation, onPreviewFile]);
@@ -110,11 +141,6 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
onPreviewFile?.(null);
}, [operation, onPreviewFile]);
// Standard computed state
const hasFiles = selectedFiles.length >= minFiles;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
return {
// File management
selectedFiles,
@@ -4,6 +4,7 @@ import apiClient from '@app/services/apiClient'; // Our configured instance
import { processResponse, ResponseHandler } from '@app/utils/toolResponseProcessor';
import { isEmptyOutput } from '@app/services/errorUtils';
import type { ProcessingProgress } from '@app/hooks/tools/shared/useToolState';
import type { StirlingFile, FileId } from '@app/types/fileContext';
export interface ApiCallsConfig<TParams = void> {
endpoint: string | ((params: TParams) => string);
@@ -18,14 +19,14 @@ export const useToolApiCalls = <TParams = void>() => {
const processFiles = useCallback(async (
params: TParams,
validFiles: File[],
validFiles: StirlingFile[],
config: ApiCallsConfig<TParams>,
onProgress: (progress: ProcessingProgress) => void,
onStatus: (status: string) => void,
markFileError?: (fileId: string) => void,
): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => {
markFileError?: (fileId: FileId) => void,
): Promise<{ outputFiles: File[]; successSourceIds: FileId[] }> => {
const processedFiles: File[] = [];
const successSourceIds: string[] = [];
const successSourceIds: FileId[] = [];
const failedFiles: string[] = [];
const total = validFiles.length;
@@ -35,7 +36,7 @@ export const useToolApiCalls = <TParams = void>() => {
for (let i = 0; i < validFiles.length; i++) {
const file = validFiles[i];
console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId });
console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: file.fileId });
onProgress({ current: i + 1, total, currentFileName: file.name });
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
@@ -47,7 +48,7 @@ export const useToolApiCalls = <TParams = void>() => {
responseType: 'blob',
cancelToken: cancelTokenRef.current?.token,
});
console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status });
console.debug('[processFiles] Response OK', { name: file.name, status: response.status });
// Forward to shared response processor (uses tool-specific responseHandler if provided)
const responseFiles = await processResponse(
@@ -63,7 +64,7 @@ export const useToolApiCalls = <TParams = void>() => {
console.warn('[processFiles] Empty output treated as failure', { name: file.name });
failedFiles.push(file.name);
try {
(markFileError as any)?.((file as any).fileId);
markFileError?.(file.fileId);
} catch (e) {
console.debug('markFileError', e);
}
@@ -71,7 +72,7 @@ export const useToolApiCalls = <TParams = void>() => {
}
processedFiles.push(...responseFiles);
// record source id as successful
successSourceIds.push((file as any).fileId);
successSourceIds.push(file.fileId);
console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length });
} catch (error) {
@@ -82,7 +83,7 @@ export const useToolApiCalls = <TParams = void>() => {
failedFiles.push(file.name);
// mark errored file so UI can highlight
try {
(markFileError as any)?.((file as any).fileId);
markFileError?.(file.fileId);
} catch (e) {
console.debug('markFileError', e);
}
@@ -8,6 +8,7 @@ import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
import { extractErrorMessage } from '@app/utils/toolErrorHandler';
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '@app/types/fileContext';
import { FILE_EVENTS } from '@app/services/errorUtils';
import { getFilenameWithoutExtension } from '@app/utils/fileUtils';
import { ResponseHandler } from '@app/utils/toolResponseProcessor';
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
import { ToolOperation } from '@app/types/file';
@@ -23,6 +24,20 @@ export enum ToolType {
custom,
}
/**
* Result from custom processor with optional metadata about input consumption.
*/
export interface CustomProcessorResult {
/** Processed output files */
files: File[];
/**
* When true, marks all input files as successfully consumed regardless of output count.
* Use when operation combines N inputs into fewer outputs (e.g., 3 images 1 PDF).
* When false/undefined, uses filename-based mapping to determine which inputs succeeded.
*/
consumedAllInputs?: boolean;
}
/**
* Configuration for tool operations defining processing behavior and API integration.
*
@@ -98,8 +113,12 @@ export interface CustomToolOperationConfig<TParams> extends BaseToolOperationCon
* Custom processing logic that completely bypasses standard file processing.
* This tool handles all API calls, response processing, and file creation.
* Use for tools with complex routing logic or non-standard processing requirements.
*
* Returns CustomProcessorResult with:
* - files: Processed output files
* - consumedAllInputs: true if operation combines N inputs fewer outputs
*/
customProcessor: (params: TParams, files: File[]) => Promise<File[]>;
customProcessor: (params: TParams, files: File[]) => Promise<CustomProcessorResult>;
}
export type ToolOperationConfig<TParams = void> = SingleFileToolOperationConfig<TParams> | MultiFileToolOperationConfig<TParams> | CustomToolOperationConfig<TParams>;
@@ -172,17 +191,17 @@ export const useToolOperation = <TParams>(
}
// Handle zero-byte inputs explicitly: mark as error and continue with others
const zeroByteFiles = selectedFiles.filter(file => (file as any)?.size === 0);
const zeroByteFiles = selectedFiles.filter(file => file.size === 0);
if (zeroByteFiles.length > 0) {
try {
for (const f of zeroByteFiles) {
(fileActions.markFileError as any)((f as any).fileId);
fileActions.markFileError(f.fileId);
}
} catch (e) {
console.log('markFileError', e);
}
}
const validFiles = selectedFiles.filter(file => (file as any)?.size > 0);
const validFiles: StirlingFile[] = selectedFiles.filter(file => file.size > 0);
if (validFiles.length === 0) {
actions.setError(t('noValidFiles', 'No valid files to process'));
return;
@@ -215,7 +234,7 @@ export const useToolOperation = <TParams>(
try {
let processedFiles: File[];
let successSourceIds: string[] = [];
let successSourceIds: FileId[] = [];
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
const filesForAPI = extractFiles(validFiles);
@@ -233,14 +252,14 @@ export const useToolOperation = <TParams>(
console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length });
const result = await processFiles(
params,
filesForAPI,
validFiles,
apiCallsConfig,
actions.setProgress,
actions.setStatus,
fileActions.markFileError as any
fileActions.markFileError
);
processedFiles = result.outputFiles;
successSourceIds = result.successSourceIds as any;
successSourceIds = result.successSourceIds;
console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length });
break;
}
@@ -268,30 +287,40 @@ export const useToolOperation = <TParams>(
processedFiles = await extractZipFiles(response.data);
}
// Assume all inputs succeeded together unless server provided an error earlier
successSourceIds = validFiles.map(f => (f as any).fileId) as any;
successSourceIds = validFiles.map(f => f.fileId);
break;
}
case ToolType.custom: {
actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, filesForAPI);
// Try to map outputs back to inputs by filename (before extension)
const inputBaseNames = new Map<string, string>();
for (const f of validFiles) {
const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase();
inputBaseNames.set(base, (f as any).fileId);
}
const mappedSuccess: string[] = [];
for (const out of processedFiles) {
const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase();
const id = inputBaseNames.get(base);
if (id) mappedSuccess.push(id);
}
// Fallback to naive alignment if names don't match
if (mappedSuccess.length === 0) {
successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any;
const result = await config.customProcessor(params, filesForAPI);
processedFiles = result.files;
const consumedAllInputs = result.consumedAllInputs || false;
// If consumedAllInputs flag is set, mark all inputs as successful
// (used for operations that combine N inputs into fewer outputs)
if (consumedAllInputs) {
successSourceIds = validFiles.map(f => f.fileId);
} else {
successSourceIds = mappedSuccess as any;
// Try to map outputs back to inputs by filename (before extension)
const inputBaseNames = new Map<string, FileId>();
for (const f of validFiles) {
const base = getFilenameWithoutExtension(f.name || '');
inputBaseNames.set(base, f.fileId);
}
const mappedSuccess: FileId[] = [];
for (const out of processedFiles) {
const base = getFilenameWithoutExtension(out.name || '');
const id = inputBaseNames.get(base);
if (id) mappedSuccess.push(id);
}
// Fallback to naive alignment if names don't match
if (mappedSuccess.length === 0) {
successSourceIds = validFiles.slice(0, processedFiles.length).map(f => f.fileId);
} else {
successSourceIds = mappedSuccess;
}
}
break;
}
@@ -299,16 +328,16 @@ export const useToolOperation = <TParams>(
// Normalize error flags across tool types: mark failures, clear successes
try {
const allInputIds = validFiles.map(f => (f as any).fileId) as unknown as string[];
const okSet = new Set((successSourceIds as unknown as string[]) || []);
const allInputIds = validFiles.map(f => f.fileId);
const okSet = new Set(successSourceIds);
// Clear errors on successes
for (const okId of okSet) {
try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; }
try { fileActions.clearFileError(okId); } catch (_e) { void _e; }
}
// Mark errors on inputs that didn't succeed
for (const id of allInputIds) {
if (!okSet.has(id)) {
try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; }
try { fileActions.markFileError(id); } catch (_e) { void _e; }
}
}
} catch (_e) { void _e; }
@@ -316,12 +345,12 @@ export const useToolOperation = <TParams>(
if (externalErrorFileIds.length > 0) {
// If backend told us which sources failed, prefer that mapping
successSourceIds = validFiles
.map(f => (f as any).fileId)
.filter(id => !externalErrorFileIds.includes(id)) as any;
.map(f => f.fileId)
.filter(id => !externalErrorFileIds.includes(id));
// Also mark failed IDs immediately
try {
for (const badId of externalErrorFileIds) {
(fileActions.markFileError as any)(badId);
fileActions.markFileError(badId as FileId);
}
} catch (_e) { void _e; }
}
@@ -370,7 +399,7 @@ export const useToolOperation = <TParams>(
);
// Always create child stubs linking back to the successful source inputs
const successInputStubs = successSourceIds
.map((id) => selectors.getStirlingFileStub(id as any))
.map((id) => selectors.getStirlingFileStub(id))
.filter(Boolean) as StirlingFileStub[];
if (successInputStubs.length !== processedFiles.length) {
@@ -396,7 +425,7 @@ export const useToolOperation = <TParams>(
return createStirlingFile(file, childStub.id);
});
// Build consumption arrays aligned to the successful source IDs
const toConsumeInputIds = successSourceIds.filter((id: string) => inputFileIds.includes(id as any)) as unknown as FileId[];
const toConsumeInputIds = successSourceIds.filter((id) => inputFileIds.includes(id));
// Outputs and stubs are already ordered by success sequence
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
@@ -413,25 +442,27 @@ export const useToolOperation = <TParams>(
} catch (error: any) {
// Centralized 422 handler: mark provided IDs in errorFileIds
try {
const status = (error?.response?.status as number | undefined);
if (status === 422) {
const status = error?.response?.status;
if (typeof status === 'number' && status === 422) {
const payload = error?.response?.data;
let parsed: any = payload;
let parsed: unknown = payload;
if (typeof payload === 'string') {
try { parsed = JSON.parse(payload); } catch { parsed = payload; }
} else if (payload && typeof (payload as any).text === 'function') {
} else if (payload && typeof (payload as Blob).text === 'function') {
// Blob or Response-like object from axios when responseType='blob'
const text = await (payload as Blob).text();
try { parsed = JSON.parse(text); } catch { parsed = text; }
}
let ids: string[] | undefined = Array.isArray(parsed?.errorFileIds) ? parsed.errorFileIds : undefined;
let ids: string[] | undefined = Array.isArray((parsed as { errorFileIds?: unknown })?.errorFileIds)
? (parsed as { errorFileIds: string[] }).errorFileIds
: undefined;
if (!ids && typeof parsed === 'string') {
const match = parsed.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
if (match && match.length > 0) ids = Array.from(new Set(match));
}
if (ids && ids.length > 0) {
for (const badId of ids) {
try { (fileActions.markFileError as any)(badId); } catch (_e) { void _e; }
try { fileActions.markFileError(badId as FileId); } catch (_e) { void _e; }
}
actions.setStatus('Process failed due to invalid/corrupted file(s)');
// Avoid duplicating toast messaging here
@@ -65,7 +65,7 @@ export function useServerExperience(): ServerExperienceValue {
const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin);
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
const hasPaidLicense = config?.license === 'PRO' || config?.license === 'ENTERPRISE';
const hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE';
const setSelfReportedAdmin = useCallback((value: boolean) => {
setSelfReportedAdminState(value);

Some files were not shown because too many files have changed in this diff Show More