mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4430763c95 | ||
|
|
0d2608bdbc | ||
|
|
9fd8fd89ed | ||
|
|
cf47378b82 | ||
|
|
fd9ba085f6 | ||
|
|
e42a124a49 | ||
|
|
3a2370ea1f | ||
|
|
38f0381dec | ||
|
|
d5f58b6d45 | ||
|
|
319df45235 | ||
|
|
e7db714091 | ||
|
|
c6b4a2b141 | ||
|
|
7459463a3c | ||
|
|
c9bf436895 | ||
|
|
f8dbf171e1 | ||
|
|
e59c717dc0 | ||
|
|
f2bffe2dc6 | ||
|
|
5d827df08c |
@@ -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
|
||||
|
||||
+6
-7
@@ -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)
|
||||
|
||||
+4
-50
@@ -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,
|
||||
|
||||
+8
@@ -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;
|
||||
}
|
||||
|
||||
+20
-4
@@ -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();
|
||||
|
||||
+11
-5
@@ -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;
|
||||
|
||||
+82
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+162
@@ -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");
|
||||
}
|
||||
}
|
||||
+218
@@ -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
@@ -57,7 +57,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.0.3'
|
||||
version = '2.1.0'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
Generated
+41
-16
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Generated
+18
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 '@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"
|
||||
@@ -2444,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";
|
||||
@@ -151,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"),
|
||||
@@ -324,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" />,
|
||||
@@ -891,23 +910,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
automationSettings: RedactSingleStepSettings,
|
||||
synonyms: getSynonyms(t, "redact")
|
||||
},
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
const regularTools = {} as RegularToolRegistry;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -59,17 +59,21 @@ export default function HomePage() {
|
||||
const prevFileCountRef = useRef(activeFiles.length);
|
||||
|
||||
// Auto-switch to viewer when going from 0 to 1 file
|
||||
// Skip this if PDF Text Editor is active - it handles its own empty state
|
||||
useEffect(() => {
|
||||
const prevCount = prevFileCountRef.current;
|
||||
const currentCount = activeFiles.length;
|
||||
|
||||
if (prevCount === 0 && currentCount === 1) {
|
||||
actions.setWorkbench('viewer');
|
||||
setActiveFileIndex(0);
|
||||
// PDF Text Editor handles its own empty state with a dropzone
|
||||
if (selectedToolKey !== 'pdfTextEditor') {
|
||||
actions.setWorkbench('viewer');
|
||||
setActiveFileIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
prevFileCountRef.current = currentCount;
|
||||
}, [activeFiles.length, actions, setActiveFileIndex]);
|
||||
}, [activeFiles.length, actions, setActiveFileIndex, selectedToolKey]);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
const brandIconSrc = useLogoPath();
|
||||
|
||||
@@ -256,6 +256,7 @@
|
||||
--header-selected-bg: #1E88E5; /* light mode selected header matches dark */
|
||||
--header-selected-fg: #FFFFFF;
|
||||
--file-card-bg: #FFFFFF; /* file card background (light/dark paired) */
|
||||
--accordion-item-bg: #E8EAED; /* accordion item background - more distinguishable */
|
||||
|
||||
/* shadows */
|
||||
--drop-shadow-color: rgba(0, 0, 0, 0.08);
|
||||
@@ -519,6 +520,7 @@
|
||||
--header-selected-fg: #FFFFFF;
|
||||
/* file card background (dark) */
|
||||
--file-card-bg: #1F2329;
|
||||
--accordion-item-bg: #373D45; /* accordion item background - more distinguishable */
|
||||
|
||||
/* shadows */
|
||||
--drop-shadow-color: rgba(255, 255, 255, 0.08);
|
||||
|
||||
@@ -23,6 +23,10 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(convertParams.getEndpointName());
|
||||
|
||||
// Prevent reset immediately after operation completes (when consumeFiles auto-selects outputs)
|
||||
const skipNextSelectionResetRef = useRef(false);
|
||||
const previousSelectionRef = useRef<string>('');
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
@@ -33,24 +37,49 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = convertOperation.downloadUrl !== null;
|
||||
const hasResults = convertOperation.files.length > 0 || convertOperation.downloadUrl !== null;
|
||||
const settingsCollapsed = hasResults;
|
||||
|
||||
// When operation completes, flag the next selection change to skip reset
|
||||
useEffect(() => {
|
||||
if (hasResults) {
|
||||
skipNextSelectionResetRef.current = true;
|
||||
}
|
||||
}, [hasResults]);
|
||||
|
||||
// Reset results when user manually changes file selection
|
||||
useEffect(() => {
|
||||
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
|
||||
// Don't analyze file types - would change parameters and trigger another reset
|
||||
if (skipNextSelectionResetRef.current) {
|
||||
skipNextSelectionResetRef.current = false;
|
||||
previousSelectionRef.current = currentSelection;
|
||||
return;
|
||||
}
|
||||
|
||||
// User manually selected different files
|
||||
if (selectedFiles.length > 0) {
|
||||
previousSelectionRef.current = currentSelection;
|
||||
convertParams.analyzeFileTypes(selectedFiles);
|
||||
if (hasResults) {
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}
|
||||
} else {
|
||||
// Only reset when there are no active files at all
|
||||
// If there are active files but no selected files, keep current format (user filtered by format)
|
||||
previousSelectionRef.current = '';
|
||||
if (activeFiles.length === 0) {
|
||||
convertParams.resetParameters();
|
||||
}
|
||||
}
|
||||
}, [selectedFiles, activeFiles, convertParams.analyzeFileTypes, convertParams.resetParameters]);
|
||||
}, [selectedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only clear results if we're not currently processing and parameters changed
|
||||
if (!convertOperation.isLoading) {
|
||||
// Reset when user changes conversion parameters (but not during operation)
|
||||
if (!convertOperation.isLoading && !skipNextSelectionResetRef.current) {
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}
|
||||
@@ -87,6 +116,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
};
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
skipNextSelectionResetRef.current = false;
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import { Stack, Group, Divider, Text, UnstyledButton } from '@mantine/core';
|
||||
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
|
||||
import { BaseToolProps, ToolComponent } from '@app/types/tool';
|
||||
import { useGetPdfInfoParameters, defaultParameters } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoParameters';
|
||||
import GetPdfInfoResults from '@app/components/tools/getPdfInfo/GetPdfInfoResults';
|
||||
import { useGetPdfInfoOperation, GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
|
||||
import GetPdfInfoReportView from '@app/components/tools/getPdfInfo/GetPdfInfoReportView';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import type { PdfInfoReportData } from '@app/types/getPdfInfo';
|
||||
|
||||
const CHAPTERS = [
|
||||
{ id: 'summary', labelKey: 'getPdfInfo.summary.title', fallback: 'PDF Summary' },
|
||||
{ id: 'metadata', labelKey: 'getPdfInfo.sections.metadata', fallback: 'Metadata' },
|
||||
{ id: 'formFields', labelKey: 'getPdfInfo.sections.formFields', fallback: 'Form Fields' },
|
||||
{ id: 'basicInfo', labelKey: 'getPdfInfo.sections.basicInfo', fallback: 'Basic Info' },
|
||||
{ id: 'documentInfo', labelKey: 'getPdfInfo.sections.documentInfo', fallback: 'Document Info' },
|
||||
{ id: 'compliance', labelKey: 'getPdfInfo.sections.compliance', fallback: 'Compliance' },
|
||||
{ id: 'encryption', labelKey: 'getPdfInfo.sections.encryption', fallback: 'Encryption' },
|
||||
{ id: 'permissions', labelKey: 'getPdfInfo.sections.permissions', fallback: 'Permissions' },
|
||||
{ id: 'toc', labelKey: 'getPdfInfo.sections.tableOfContents', fallback: 'Table of Contents' },
|
||||
{ id: 'other', labelKey: 'getPdfInfo.sections.other', fallback: 'Other' },
|
||||
{ id: 'perPage', labelKey: 'getPdfInfo.sections.perPageInfo', fallback: 'Per Page Info' },
|
||||
];
|
||||
|
||||
const GetPdfInfo = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const REPORT_VIEW_ID = 'getPdfInfoReport';
|
||||
const REPORT_WORKBENCH_ID = 'custom:getPdfInfoReport' as const;
|
||||
const reportIcon = useMemo(() => <PictureAsPdfIcon fontSize="small" />, []);
|
||||
|
||||
const base = useBaseTool(
|
||||
'getPdfInfo',
|
||||
useGetPdfInfoParameters,
|
||||
useGetPdfInfoOperation,
|
||||
props
|
||||
);
|
||||
|
||||
const operation = base.operation as GetPdfInfoOperationHook;
|
||||
const hasResults = operation.results.length > 0;
|
||||
const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: REPORT_VIEW_ID,
|
||||
workbenchId: REPORT_WORKBENCH_ID,
|
||||
label: t('getPdfInfo.report.shortTitle', 'PDF Information'),
|
||||
icon: reportIcon,
|
||||
component: GetPdfInfoReportView,
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(REPORT_VIEW_ID);
|
||||
};
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
registerCustomWorkbenchView,
|
||||
reportIcon,
|
||||
t,
|
||||
unregisterCustomWorkbenchView,
|
||||
]);
|
||||
|
||||
const reportData = useMemo<PdfInfoReportData | null>(() => {
|
||||
if (operation.results.length === 0) return null;
|
||||
const generatedAt = operation.results[0].summaryGeneratedAt ?? Date.now();
|
||||
return {
|
||||
generatedAt,
|
||||
entries: operation.results,
|
||||
};
|
||||
}, [operation.results]);
|
||||
|
||||
const lastReportGeneratedAtRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (reportData) {
|
||||
setCustomWorkbenchViewData(REPORT_VIEW_ID, reportData);
|
||||
const generatedAt = reportData.generatedAt ?? null;
|
||||
const isNewReport = generatedAt && generatedAt !== lastReportGeneratedAtRef.current;
|
||||
if (isNewReport) {
|
||||
lastReportGeneratedAtRef.current = generatedAt;
|
||||
if (navigationState.selectedTool === 'getPdfInfo' && navigationState.workbench !== REPORT_WORKBENCH_ID) {
|
||||
navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
|
||||
lastReportGeneratedAtRef.current = null;
|
||||
}
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
navigationActions,
|
||||
navigationState.selectedTool,
|
||||
navigationState.workbench,
|
||||
reportData,
|
||||
setCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t('getPdfInfo.indexTitle', 'Index'),
|
||||
isVisible: Boolean(reportData),
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<Stack gap={0}>
|
||||
{CHAPTERS.map((c, idx) => (
|
||||
<Stack key={c.id} gap={0}>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
if (!reportData) return;
|
||||
setCustomWorkbenchViewData(REPORT_VIEW_ID, { ...reportData, scrollTo: c.id });
|
||||
if (navigationState.workbench !== REPORT_WORKBENCH_ID) {
|
||||
navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', textAlign: 'left', padding: '8px 4px' }}
|
||||
>
|
||||
<Group justify="flex-start" gap="sm">
|
||||
<LinkIcon fontSize="small" style={{ opacity: 0.7 }} />
|
||||
<Text size="md" c="dimmed">
|
||||
{t(c.labelKey, c.fallback)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{idx < CHAPTERS.length - 1 && <Divider my={6} />}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('getPdfInfo.results', 'Results'),
|
||||
isVisible: showResultsStep,
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<GetPdfInfoResults
|
||||
operation={operation}
|
||||
isLoading={base.operation.isLoading}
|
||||
errorMessage={base.operation.errorMessage}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t('getPdfInfo.submit', 'Generate'),
|
||||
loadingText: t('loading', 'Loading...'),
|
||||
onClick: base.handleExecute,
|
||||
disabled:
|
||||
!base.params.validateParameters() ||
|
||||
!base.hasFiles ||
|
||||
base.operation.isLoading ||
|
||||
!base.endpointEnabled,
|
||||
isVisible: true,
|
||||
},
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: base.operation,
|
||||
title: t('getPdfInfo.results', 'Results'),
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const GetPdfInfoTool = GetPdfInfo as ToolComponent;
|
||||
GetPdfInfoTool.tool = () => useGetPdfInfoOperation;
|
||||
GetPdfInfoTool.getDefaultParameters = () => ({ ...defaultParameters });
|
||||
|
||||
export default GetPdfInfoTool;
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
|
||||
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useFileSelection } from '@app/contexts/FileContext';
|
||||
import { useFileSelection, useFileManagement, useFileContext } from '@app/contexts/FileContext';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||
import { BaseToolProps, ToolComponent } from '@app/types/tool';
|
||||
import { getDefaultWorkbench } from '@app/types/workbench';
|
||||
import { CONVERSION_ENDPOINTS } from '@app/constants/convertConstants';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { downloadBlob, downloadTextAsFile } from '@app/utils/downloadUtils';
|
||||
@@ -208,7 +210,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
} = useToolWorkflow();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
const { registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = navigationActions;
|
||||
const { addFiles } = useFileManagement();
|
||||
const { consumeFiles, selectors } = useFileContext();
|
||||
|
||||
const [loadedDocument, setLoadedDocument] = useState<PdfJsonDocument | null>(null);
|
||||
const [groupsByPage, setGroupsByPage] = useState<TextGroup[][]>([]);
|
||||
@@ -217,6 +220,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||
const [isSavingToWorkbench, setIsSavingToWorkbench] = useState(false);
|
||||
const [shouldNavigateAfterSave, setShouldNavigateAfterSave] = useState(false);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [conversionProgress, setConversionProgress] = useState<ConversionProgress | null>(null);
|
||||
const [forceSingleTextElement, setForceSingleTextElement] = useState(true);
|
||||
@@ -234,6 +239,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const originalGroupsRef = useRef<TextGroup[][]>([]);
|
||||
const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]);
|
||||
const autoLoadKeyRef = useRef<string | null>(null);
|
||||
const sourceFileIdRef = useRef<string | null>(null);
|
||||
const loadRequestIdRef = useRef(0);
|
||||
const latestPdfRequestIdRef = useRef<number | null>(null);
|
||||
const loadedDocumentRef = useRef<PdfJsonDocument | null>(null);
|
||||
@@ -279,6 +285,23 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
);
|
||||
const hasChanges = useMemo(() => dirtyPages.some(Boolean), [dirtyPages]);
|
||||
const hasDocument = loadedDocument !== null;
|
||||
|
||||
// Sync hasChanges to navigation context so navigation guards can block
|
||||
useEffect(() => {
|
||||
navigationActions.setHasUnsavedChanges(hasChanges);
|
||||
return () => {
|
||||
navigationActions.setHasUnsavedChanges(false);
|
||||
};
|
||||
}, [hasChanges, navigationActions]);
|
||||
|
||||
// Navigate to files view AFTER the unsaved changes state is properly cleared
|
||||
useEffect(() => {
|
||||
if (shouldNavigateAfterSave && !navigationState.hasUnsavedChanges) {
|
||||
setShouldNavigateAfterSave(false);
|
||||
navigationActions.setToolAndWorkbench(null, getDefaultWorkbench());
|
||||
}
|
||||
}, [shouldNavigateAfterSave, navigationState.hasUnsavedChanges, navigationActions]);
|
||||
|
||||
const viewLabel = useMemo(() => t('pdfTextEditor.viewLabel', 'PDF Editor'), [t]);
|
||||
const { selectedFiles } = useFileSelection();
|
||||
|
||||
@@ -720,6 +743,21 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
[groupingMode, resetToDocument, t],
|
||||
);
|
||||
|
||||
// Wrapper for loading files from the dropzone - adds to workbench first
|
||||
const handleLoadFileFromDropzone = useCallback(
|
||||
async (file: File) => {
|
||||
// Add the file to the workbench so it appears in the file list
|
||||
const addedFiles = await addFiles([file]);
|
||||
// Capture the file ID for save-to-workbench functionality
|
||||
if (addedFiles.length > 0 && addedFiles[0].fileId) {
|
||||
sourceFileIdRef.current = addedFiles[0].fileId;
|
||||
}
|
||||
// Then load it into the editor
|
||||
void handleLoadFile(file);
|
||||
},
|
||||
[addFiles, handleLoadFile],
|
||||
);
|
||||
|
||||
const handleSelectPage = useCallback((pageIndex: number) => {
|
||||
setSelectedPage(pageIndex);
|
||||
// Trigger lazy loading for images on the selected page
|
||||
@@ -1122,6 +1160,229 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
t,
|
||||
]);
|
||||
|
||||
// Save changes to workbench (replaces the original file with edited version)
|
||||
const handleSaveToWorkbench = useCallback(async () => {
|
||||
setIsSavingToWorkbench(true);
|
||||
|
||||
try {
|
||||
if (!sourceFileIdRef.current) {
|
||||
console.warn('[PdfTextEditor] No source file ID available for save to workbench');
|
||||
// Fall back to generating PDF download if no source file
|
||||
await handleGeneratePdf(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const parentStub = selectors.getStirlingFileStub(sourceFileIdRef.current as any);
|
||||
if (!parentStub) {
|
||||
console.warn('[PdfTextEditor] Could not find parent stub for save to workbench');
|
||||
await handleGeneratePdf(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const ensureImagesForPages = async (pageIndices: number[]) => {
|
||||
const uniqueIndices = Array.from(new Set(pageIndices)).filter((index) => index >= 0);
|
||||
if (uniqueIndices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const index of uniqueIndices) {
|
||||
if (!loadedImagePagesRef.current.has(index)) {
|
||||
await loadImagesForPage(index);
|
||||
}
|
||||
}
|
||||
|
||||
const maxWaitTime = 15000;
|
||||
const pollInterval = 150;
|
||||
const startWait = Date.now();
|
||||
while (Date.now() - startWait < maxWaitTime) {
|
||||
const allLoaded = uniqueIndices.every(
|
||||
(index) =>
|
||||
loadedImagePagesRef.current.has(index) &&
|
||||
imagesByPageRef.current[index] !== undefined,
|
||||
);
|
||||
const anyLoading = uniqueIndices.some((index) =>
|
||||
loadingImagePagesRef.current.has(index),
|
||||
);
|
||||
if (allLoaded && !anyLoading) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
const missing = uniqueIndices.filter(
|
||||
(index) => !loadedImagePagesRef.current.has(index),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to load images for pages ${missing.map((i) => i + 1).join(', ')}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const currentDoc = loadedDocumentRef.current;
|
||||
const totalPages = currentDoc?.pages?.length ?? 0;
|
||||
const currentDirtyPages = getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current);
|
||||
const dirtyPageIndices = currentDirtyPages
|
||||
.map((isDirty, index) => (isDirty ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
|
||||
let pdfBlob: Blob;
|
||||
let downloadName: string;
|
||||
|
||||
const canUseIncremental =
|
||||
isLazyMode &&
|
||||
cachedJobId &&
|
||||
dirtyPageIndices.length > 0 &&
|
||||
dirtyPageIndices.length < totalPages;
|
||||
|
||||
if (canUseIncremental) {
|
||||
await ensureImagesForPages(dirtyPageIndices);
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
throw new Error('Failed to build payload');
|
||||
}
|
||||
|
||||
const { document, filename } = payload;
|
||||
const dirtyPageSet = new Set(dirtyPageIndices);
|
||||
const partialPages =
|
||||
document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? [];
|
||||
|
||||
const partialDocument: PdfJsonDocument = {
|
||||
metadata: document.metadata,
|
||||
xmpMetadata: document.xmpMetadata,
|
||||
fonts: document.fonts,
|
||||
lazyImages: true,
|
||||
pages: partialPages,
|
||||
};
|
||||
|
||||
const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
|
||||
const expectedName = `${baseName || 'document'}.pdf`;
|
||||
const response = await apiClient.post(
|
||||
`/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`,
|
||||
partialDocument,
|
||||
{
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
|
||||
const contentDisposition = response.headers?.['content-disposition'] ?? '';
|
||||
const detectedName = getFilenameFromHeaders(contentDisposition);
|
||||
downloadName = detectedName || expectedName;
|
||||
pdfBlob = response.data;
|
||||
} catch (incrementalError) {
|
||||
console.warn(
|
||||
'[handleSaveToWorkbench] Incremental export failed, falling back to full export',
|
||||
incrementalError,
|
||||
);
|
||||
// Fall through to full export
|
||||
if (isLazyMode && totalPages > 0) {
|
||||
const allPageIndices = Array.from({ length: totalPages }, (_, index) => index);
|
||||
await ensureImagesForPages(allPageIndices);
|
||||
}
|
||||
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
throw new Error('Failed to build payload');
|
||||
}
|
||||
|
||||
const { document, filename } = payload;
|
||||
const serialized = JSON.stringify(document);
|
||||
const jsonFile = new File([serialized], filename, { type: 'application/json' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', jsonFile);
|
||||
const response = await apiClient.post(CONVERSION_ENDPOINTS['text-editor-pdf'], formData, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const contentDisposition = response.headers?.['content-disposition'] ?? '';
|
||||
const detectedName = getFilenameFromHeaders(contentDisposition);
|
||||
const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
|
||||
downloadName = detectedName || `${baseName || 'document'}.pdf`;
|
||||
pdfBlob = response.data;
|
||||
}
|
||||
} else {
|
||||
if (isLazyMode && totalPages > 0) {
|
||||
const allPageIndices = Array.from({ length: totalPages }, (_, index) => index);
|
||||
await ensureImagesForPages(allPageIndices);
|
||||
}
|
||||
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
throw new Error('Failed to build payload');
|
||||
}
|
||||
|
||||
const { document, filename } = payload;
|
||||
const serialized = JSON.stringify(document);
|
||||
const jsonFile = new File([serialized], filename, { type: 'application/json' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', jsonFile);
|
||||
const response = await apiClient.post(CONVERSION_ENDPOINTS['text-editor-pdf'], formData, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const contentDisposition = response.headers?.['content-disposition'] ?? '';
|
||||
const detectedName = getFilenameFromHeaders(contentDisposition);
|
||||
const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
|
||||
downloadName = detectedName || `${baseName || 'document'}.pdf`;
|
||||
pdfBlob = response.data;
|
||||
}
|
||||
|
||||
// Create the new PDF file
|
||||
const pdfFile = new File([pdfBlob], downloadName, { type: 'application/pdf' });
|
||||
|
||||
// Create StirlingFile and stub for the output
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(
|
||||
[pdfFile],
|
||||
parentStub,
|
||||
'pdfTextEditor',
|
||||
);
|
||||
|
||||
// Replace the original file with the edited version
|
||||
await consumeFiles([sourceFileIdRef.current as any], stirlingFiles, stubs);
|
||||
|
||||
// Update the source file ID to point to the new file
|
||||
sourceFileIdRef.current = stubs[0].id;
|
||||
|
||||
// Clear the unsaved changes flag - this will trigger the useEffect to navigate
|
||||
// once React has processed the state update
|
||||
navigationActions.setHasUnsavedChanges(false);
|
||||
setErrorMessage(null);
|
||||
|
||||
// Set flag to trigger navigation after state update is processed
|
||||
setShouldNavigateAfterSave(true);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save to workbench', error);
|
||||
const message =
|
||||
error?.response?.data ||
|
||||
error?.message ||
|
||||
t('pdfTextEditor.errors.pdfConversion', 'Unable to save changes to workbench.');
|
||||
const msgString = typeof message === 'string' ? message : String(message);
|
||||
setErrorMessage(msgString);
|
||||
if (onError) {
|
||||
onError(msgString);
|
||||
}
|
||||
} finally {
|
||||
setIsSavingToWorkbench(false);
|
||||
}
|
||||
}, [
|
||||
buildPayload,
|
||||
cachedJobId,
|
||||
consumeFiles,
|
||||
groupsByPage,
|
||||
handleGeneratePdf,
|
||||
imagesByPage,
|
||||
isLazyMode,
|
||||
loadImagesForPage,
|
||||
navigationActions,
|
||||
onError,
|
||||
selectors,
|
||||
t,
|
||||
]);
|
||||
|
||||
const requestPagePreview = useCallback(
|
||||
async (pageIndex: number, scale: number) => {
|
||||
if (!hasVectorPreview || !pdfDocumentRef.current) {
|
||||
@@ -1260,6 +1521,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
fileName,
|
||||
errorMessage,
|
||||
isGeneratingPdf,
|
||||
isSavingToWorkbench,
|
||||
isConverting,
|
||||
conversionProgress,
|
||||
hasChanges,
|
||||
@@ -1278,15 +1540,19 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
// Generate PDF without triggering tool completion
|
||||
await handleGeneratePdf(true);
|
||||
},
|
||||
onSaveToWorkbench: handleSaveToWorkbench,
|
||||
onForceSingleTextElementChange: setForceSingleTextElement,
|
||||
onGroupingModeChange: setGroupingMode,
|
||||
onMergeGroups: handleMergeGroups,
|
||||
onUngroupGroup: handleUngroupGroup,
|
||||
onLoadFile: handleLoadFileFromDropzone,
|
||||
}), [
|
||||
handleMergeGroups,
|
||||
handleUngroupGroup,
|
||||
handleImageTransform,
|
||||
handleSaveToWorkbench,
|
||||
imagesByPage,
|
||||
isSavingToWorkbench,
|
||||
pagePreviews,
|
||||
dirtyPages,
|
||||
errorMessage,
|
||||
@@ -1311,6 +1577,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
groupingMode,
|
||||
requestPagePreview,
|
||||
setForceSingleTextElement,
|
||||
handleLoadFileFromDropzone,
|
||||
]);
|
||||
|
||||
const latestViewDataRef = useRef<PdfTextEditorViewData>(viewData);
|
||||
@@ -1326,6 +1593,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length === 0) {
|
||||
autoLoadKeyRef.current = null;
|
||||
sourceFileIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1344,6 +1612,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
}
|
||||
|
||||
autoLoadKeyRef.current = fileKey;
|
||||
// Capture the source file ID for save-to-workbench functionality
|
||||
sourceFileIdRef.current = (file as any).fileId ?? null;
|
||||
void handleLoadFile(file);
|
||||
}, [selectedFiles, navigationState.selectedTool, handleLoadFile]);
|
||||
|
||||
@@ -1398,27 +1668,6 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
// The workbench should be set when the tool is selected via proper channels
|
||||
// (tool registry, tool picker, etc.) - not forced here
|
||||
|
||||
// Keep hasChanges in a ref for the checker to access
|
||||
const hasChangesRef = useRef(hasChanges);
|
||||
useEffect(() => {
|
||||
hasChangesRef.current = hasChanges;
|
||||
console.log('[PdfTextEditor] hasChanges updated to:', hasChanges);
|
||||
}, [hasChanges]);
|
||||
|
||||
// Register unsaved changes checker for navigation guard
|
||||
useEffect(() => {
|
||||
const checker = () => {
|
||||
console.log('[PdfTextEditor] Checking unsaved changes:', hasChangesRef.current);
|
||||
return hasChangesRef.current;
|
||||
};
|
||||
registerUnsavedChangesChecker(checker);
|
||||
console.log('[PdfTextEditor] Registered unsaved changes checker');
|
||||
return () => {
|
||||
console.log('[PdfTextEditor] Unregistered unsaved changes checker');
|
||||
unregisterUnsavedChangesChecker();
|
||||
};
|
||||
}, [registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||
|
||||
const lastSentViewDataRef = useRef<PdfTextEditorViewData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -221,8 +221,11 @@ export interface PdfTextEditorViewData {
|
||||
onDownloadJson: () => void;
|
||||
onGeneratePdf: () => void;
|
||||
onGeneratePdfForNavigation: () => Promise<void>;
|
||||
onSaveToWorkbench: () => Promise<void>;
|
||||
isSavingToWorkbench: boolean;
|
||||
onForceSingleTextElementChange: (value: boolean) => void;
|
||||
onGroupingModeChange: (value: 'auto' | 'paragraph' | 'singleLine') => void;
|
||||
onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean;
|
||||
onUngroupGroup: (pageIndex: number, groupId: string) => boolean;
|
||||
onLoadFile: (file: File) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/** Metadata section from PDF */
|
||||
export interface PdfMetadata {
|
||||
Title?: string | null;
|
||||
Author?: string | null;
|
||||
Subject?: string | null;
|
||||
Keywords?: string | null;
|
||||
Creator?: string | null;
|
||||
Producer?: string | null;
|
||||
CreationDate?: string | null;
|
||||
ModificationDate?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Basic info section */
|
||||
export interface PdfBasicInfo {
|
||||
FileSizeInBytes?: number;
|
||||
WordCount?: number;
|
||||
ParagraphCount?: number;
|
||||
CharacterCount?: number;
|
||||
Compression?: boolean;
|
||||
CompressionType?: string;
|
||||
Language?: string | null;
|
||||
'Number of pages'?: number;
|
||||
TotalImages?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Document info section */
|
||||
export interface PdfDocumentInfo {
|
||||
'PDF version'?: string;
|
||||
Trapped?: string | null;
|
||||
'Page Mode'?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Encryption section */
|
||||
export interface PdfEncryption {
|
||||
IsEncrypted?: boolean;
|
||||
EncryptionAlgorithm?: string;
|
||||
KeyLength?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Permissions section - values are "Allowed" or "Not Allowed" */
|
||||
export interface PdfPermissions {
|
||||
'Document Assembly'?: 'Allowed' | 'Not Allowed';
|
||||
'Extracting Content'?: 'Allowed' | 'Not Allowed';
|
||||
'Extracting for accessibility'?: 'Allowed' | 'Not Allowed';
|
||||
'Form Filling'?: 'Allowed' | 'Not Allowed';
|
||||
'Modifying'?: 'Allowed' | 'Not Allowed';
|
||||
'Modifying annotations'?: 'Allowed' | 'Not Allowed';
|
||||
'Printing'?: 'Allowed' | 'Not Allowed';
|
||||
[key: string]: 'Allowed' | 'Not Allowed' | undefined;
|
||||
}
|
||||
|
||||
/** Compliance section */
|
||||
export interface PdfCompliance {
|
||||
'IsPDF/ACompliant'?: boolean;
|
||||
'PDF/AConformanceLevel'?: string;
|
||||
'IsPDF/AValidated'?: boolean;
|
||||
'IsPDF/XCompliant'?: boolean;
|
||||
'IsPDF/ECompliant'?: boolean;
|
||||
'IsPDF/VTCompliant'?: boolean;
|
||||
'IsPDF/UACompliant'?: boolean;
|
||||
'IsPDF/BCompliant'?: boolean;
|
||||
'IsPDF/SECCompliant'?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Font info within a page */
|
||||
export interface PdfFontInfo {
|
||||
Name?: string;
|
||||
IsEmbedded?: boolean;
|
||||
Subtype?: string;
|
||||
ItalicAngle?: number;
|
||||
IsItalic?: boolean;
|
||||
IsBold?: boolean;
|
||||
IsFixedPitch?: boolean;
|
||||
IsSerif?: boolean;
|
||||
IsSymbolic?: boolean;
|
||||
IsScript?: boolean;
|
||||
IsNonsymbolic?: boolean;
|
||||
FontFamily?: string;
|
||||
FontWeight?: number;
|
||||
Count?: number;
|
||||
}
|
||||
|
||||
/** Image info within a page */
|
||||
export interface PdfImageInfo {
|
||||
Width?: number;
|
||||
Height?: number;
|
||||
Name?: string;
|
||||
ColorSpace?: string;
|
||||
}
|
||||
|
||||
/** Link info within a page */
|
||||
export interface PdfLinkInfo {
|
||||
URI?: string;
|
||||
}
|
||||
|
||||
/** Annotations info within a page */
|
||||
export interface PdfAnnotationsInfo {
|
||||
AnnotationsCount?: number;
|
||||
SubtypeCount?: number;
|
||||
ContentsCount?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Size/dimensions info within a page */
|
||||
export interface PdfSizeInfo {
|
||||
'Width (px)'?: string;
|
||||
'Height (px)'?: string;
|
||||
'Width (in)'?: string;
|
||||
'Height (in)'?: string;
|
||||
'Width (cm)'?: string;
|
||||
'Height (cm)'?: string;
|
||||
'Standard Page'?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** XObject counts within a page */
|
||||
export interface PdfXObjectCounts {
|
||||
Image?: number;
|
||||
Form?: number;
|
||||
Other?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** ICC Profile info */
|
||||
export interface PdfICCProfile {
|
||||
'ICC Profile Length'?: number;
|
||||
}
|
||||
|
||||
/** Page-level information */
|
||||
export interface PdfPageInfo {
|
||||
Size?: PdfSizeInfo;
|
||||
Rotation?: number;
|
||||
'Page Orientation'?: string;
|
||||
MediaBox?: string;
|
||||
CropBox?: string;
|
||||
BleedBox?: string;
|
||||
TrimBox?: string;
|
||||
ArtBox?: string;
|
||||
'Text Characters Count'?: number;
|
||||
Annotations?: PdfAnnotationsInfo;
|
||||
Images?: PdfImageInfo[];
|
||||
Links?: PdfLinkInfo[];
|
||||
Fonts?: PdfFontInfo[];
|
||||
'Color Spaces & ICC Profiles'?: PdfICCProfile[];
|
||||
XObjectCounts?: PdfXObjectCounts;
|
||||
Multimedia?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Per-page info section (keyed by "Page 1", "Page 2", etc.) */
|
||||
export interface PdfPerPageInfo {
|
||||
[pageLabel: string]: PdfPageInfo;
|
||||
}
|
||||
|
||||
/** Embedded file info */
|
||||
export interface PdfEmbeddedFileInfo {
|
||||
Name?: string;
|
||||
FileSize?: number;
|
||||
}
|
||||
|
||||
/** Attachment info */
|
||||
export interface PdfAttachmentInfo {
|
||||
Name?: string;
|
||||
Description?: string;
|
||||
}
|
||||
|
||||
/** JavaScript info */
|
||||
export interface PdfJavaScriptInfo {
|
||||
'JS Name'?: string;
|
||||
'JS Script Length'?: number;
|
||||
}
|
||||
|
||||
/** Layer info */
|
||||
export interface PdfLayerInfo {
|
||||
Name?: string;
|
||||
}
|
||||
|
||||
/** Structure tree element */
|
||||
export interface PdfStructureTreeElement {
|
||||
Type?: string;
|
||||
Content?: string;
|
||||
Children?: PdfStructureTreeElement[];
|
||||
}
|
||||
|
||||
/** Other section with miscellaneous data */
|
||||
export interface PdfOtherInfo {
|
||||
Attachments?: PdfAttachmentInfo[];
|
||||
EmbeddedFiles?: PdfEmbeddedFileInfo[];
|
||||
JavaScript?: PdfJavaScriptInfo[];
|
||||
Layers?: PdfLayerInfo[];
|
||||
StructureTree?: PdfStructureTreeElement[];
|
||||
'Bookmarks/Outline/TOC'?: PdfTocEntry[];
|
||||
XMPMetadata?: string | null;
|
||||
}
|
||||
|
||||
/** Table of contents bookmark entry */
|
||||
export interface PdfTocEntry {
|
||||
Title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Summary data section */
|
||||
export interface PdfSummaryData {
|
||||
encrypted?: boolean;
|
||||
restrictedPermissions?: string[];
|
||||
restrictedPermissionsCount?: number;
|
||||
standardCompliance?: string;
|
||||
standardPurpose?: string;
|
||||
standardValidationPassed?: boolean;
|
||||
}
|
||||
|
||||
/** Form fields section */
|
||||
export type PdfFormFields = Record<string, string>;
|
||||
|
||||
/** Parsed sections with normalized keys for frontend use */
|
||||
export interface ParsedPdfSections {
|
||||
metadata?: PdfMetadata | null;
|
||||
formFields?: PdfFormFields | null;
|
||||
basicInfo?: PdfBasicInfo | null;
|
||||
documentInfo?: PdfDocumentInfo | null;
|
||||
compliance?: PdfCompliance | null;
|
||||
encryption?: PdfEncryption | null;
|
||||
permissions?: PdfPermissions | null;
|
||||
toc?: PdfTocEntry[] | null;
|
||||
other?: PdfOtherInfo | null;
|
||||
perPage?: PdfPerPageInfo | null;
|
||||
summaryData?: PdfSummaryData | null;
|
||||
}
|
||||
|
||||
/** Raw backend response structure */
|
||||
export interface PdfInfoBackendData {
|
||||
Metadata?: PdfMetadata;
|
||||
FormFields?: PdfFormFields;
|
||||
BasicInfo?: PdfBasicInfo;
|
||||
DocumentInfo?: PdfDocumentInfo;
|
||||
Compliancy?: PdfCompliance;
|
||||
Encryption?: PdfEncryption;
|
||||
Permissions?: PdfPermissions;
|
||||
Other?: PdfOtherInfo;
|
||||
PerPageInfo?: PdfPerPageInfo;
|
||||
SummaryData?: PdfSummaryData;
|
||||
// Legacy/alternative keys for backwards compatibility
|
||||
'Form Fields'?: PdfFormFields;
|
||||
'Basic Info'?: PdfBasicInfo;
|
||||
'Document Info'?: PdfDocumentInfo;
|
||||
Compliance?: PdfCompliance;
|
||||
'Bookmarks/Outline/TOC'?: PdfTocEntry[];
|
||||
'Table of Contents'?: PdfTocEntry[];
|
||||
'Per Page Info'?: PdfPerPageInfo;
|
||||
}
|
||||
|
||||
export interface PdfInfoReportEntry {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number | null;
|
||||
lastModified: number | null;
|
||||
thumbnailUrl?: string | null;
|
||||
data: PdfInfoBackendData;
|
||||
error: string | null;
|
||||
summaryGeneratedAt?: number;
|
||||
}
|
||||
|
||||
export interface PdfInfoReportData {
|
||||
generatedAt: number;
|
||||
entries: PdfInfoReportEntry[];
|
||||
}
|
||||
|
||||
export const INFO_JSON_FILENAME = 'response.json';
|
||||
export const INFO_PDF_FILENAME = 'pdf-information-report.pdf';
|
||||
@@ -158,8 +158,8 @@ export const executeToolOperationWithPrefix = async (
|
||||
try {
|
||||
// Check if tool uses custom processor (like Convert tool)
|
||||
if (config.customProcessor) {
|
||||
const resultFiles = await config.customProcessor(parameters, files);
|
||||
return resultFiles;
|
||||
const result = await config.customProcessor(parameters, files);
|
||||
return result.files;
|
||||
}
|
||||
|
||||
// Execute based on tool type
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* File processing utilities specifically for automation workflows
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
import { ResourceManager } from '@app/utils/resourceManager';
|
||||
import { AUTOMATION_CONSTANTS } from '@app/constants/automation';
|
||||
@@ -97,7 +97,7 @@ export class AutomationFileProcessor {
|
||||
options: AutomationProcessingOptions = {}
|
||||
): Promise<AutomationProcessingResult> {
|
||||
try {
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
const response = await apiClient.post(endpoint, formData, {
|
||||
responseType: options.responseType || 'blob',
|
||||
timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
|
||||
});
|
||||
@@ -139,7 +139,7 @@ export class AutomationFileProcessor {
|
||||
options: AutomationProcessingOptions = {}
|
||||
): Promise<AutomationProcessingResult> {
|
||||
try {
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
const response = await apiClient.post(endpoint, formData, {
|
||||
responseType: options.responseType || 'blob',
|
||||
timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
|
||||
});
|
||||
|
||||
@@ -60,6 +60,18 @@ export const isWebFormat = (extension: string): boolean => {
|
||||
return ['html', 'zip'].includes(extension.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given extension is an office format (Word, Excel, PowerPoint, OpenOffice)
|
||||
* These formats use LibreOffice for conversion and require individual file processing
|
||||
*/
|
||||
export const isOfficeFormat = (extension: string): boolean => {
|
||||
return [
|
||||
'docx', 'doc', 'odt', // Word processors
|
||||
'xlsx', 'xls', 'ods', // Spreadsheets
|
||||
'pptx', 'ppt', 'odp' // Presentations
|
||||
].includes(extension.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets available target extensions for a given source extension
|
||||
* Extracted from useConvertParameters to be reusable in automation settings
|
||||
|
||||
@@ -52,6 +52,29 @@ export function detectFileExtension(filename: string): string {
|
||||
return extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the file extension from a filename
|
||||
* @param filename - The filename to process
|
||||
* @param options - Options for processing
|
||||
* @param options.preserveCase - If true, preserves original case. If false (default), converts to lowercase
|
||||
* @returns Filename without extension
|
||||
* @example
|
||||
* getFilenameWithoutExtension('document.pdf') // 'document'
|
||||
* getFilenameWithoutExtension('my.file.name.txt') // 'my.file.name'
|
||||
* getFilenameWithoutExtension('REPORT.PDF', { preserveCase: true }) // 'REPORT'
|
||||
*/
|
||||
export function getFilenameWithoutExtension(
|
||||
filename: string,
|
||||
options: { preserveCase?: boolean } = {}
|
||||
): string {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
|
||||
const { preserveCase = false } = options;
|
||||
const withoutExtension = filename.replace(/\.[^.]+$/, '');
|
||||
|
||||
return preserveCase ? withoutExtension : withoutExtension.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is a PDF based on extension and MIME type
|
||||
* @param file - File or file-like object with name and type properties
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
|
||||
import buildLoginSlides from '@app/components/shared/loginSlides';
|
||||
import styles from '@app/routes/authShared/AuthLayout.module.css';
|
||||
import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
||||
|
||||
interface DesktopAuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
const logoVariant = useLogoVariant();
|
||||
const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
|
||||
|
||||
// Force light mode on auth pages
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
|
||||
|
||||
// Set light mode
|
||||
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
|
||||
|
||||
// Cleanup: restore previous theme when leaving auth pages
|
||||
return () => {
|
||||
if (previousColorScheme) {
|
||||
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
// Use viewport to avoid hysteresis when the card is already in single-column mode
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
|
||||
const columnWidth = cardWidthIfTwoCols / 2;
|
||||
const tooNarrow = columnWidth < 470;
|
||||
const tooShort = viewportHeight < 740;
|
||||
setHideRightPanel(tooNarrow || tooShort);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('orientationchange', update);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
|
||||
>
|
||||
<div className={styles.authLeftPanel}>
|
||||
<div className={styles.authContent}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authService, UserInfo } from '@app/services/authService';
|
||||
import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
export type OAuthProvider = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc';
|
||||
|
||||
interface DesktopOAuthButtonsProps {
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onError: (error: string) => void;
|
||||
isDisabled: boolean;
|
||||
serverUrl: string;
|
||||
providers: OAuthProvider[];
|
||||
}
|
||||
|
||||
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
onOAuthSuccess,
|
||||
onError,
|
||||
isDisabled,
|
||||
serverUrl,
|
||||
providers,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
|
||||
const handleOAuthLogin = async (provider: OAuthProvider) => {
|
||||
// Prevent concurrent OAuth attempts
|
||||
if (oauthLoading || isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOauthLoading(true);
|
||||
|
||||
// Build callback page HTML with translations and dark mode support
|
||||
const successHtml = buildOAuthCallbackHtml({
|
||||
title: t('oauth.success.title', 'Authentication Successful'),
|
||||
message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'),
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const errorHtml = buildOAuthCallbackHtml({
|
||||
title: t('oauth.error.title', 'Authentication Failed'),
|
||||
message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'),
|
||||
isError: true,
|
||||
errorPlaceholder: true, // {error} will be replaced by Rust
|
||||
});
|
||||
|
||||
const userInfo = await authService.loginWithOAuth(provider, serverUrl, successHtml, errorHtml);
|
||||
|
||||
// Call the onOAuthSuccess callback to complete setup
|
||||
await onOAuthSuccess(userInfo);
|
||||
} catch (error) {
|
||||
console.error('OAuth login failed:', error);
|
||||
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.');
|
||||
|
||||
onError(errorMessage);
|
||||
setOauthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const providerConfig: Record<OAuthProvider, { label: string; file: string }> = {
|
||||
google: { label: 'Google', file: 'google.svg' },
|
||||
github: { label: 'GitHub', file: 'github.svg' },
|
||||
keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
|
||||
azure: { label: 'Microsoft', file: 'microsoft.svg' },
|
||||
apple: { label: 'Apple', file: 'apple.svg' },
|
||||
oidc: { label: 'OpenID', file: 'oidc.svg' },
|
||||
};
|
||||
|
||||
if (providers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-container-vertical">
|
||||
{providers
|
||||
.filter((providerId) => providerId in providerConfig)
|
||||
.map((providerId) => {
|
||||
const provider = providerConfig[providerId];
|
||||
return (
|
||||
<button
|
||||
key={providerId}
|
||||
onClick={() => handleOAuthLogin(providerId)}
|
||||
disabled={isDisabled || oauthLoading}
|
||||
className="oauth-button-vertical"
|
||||
title={provider.label}
|
||||
>
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/${provider.file}`}
|
||||
alt={provider.label}
|
||||
className="oauth-icon-tiny"
|
||||
/>
|
||||
{provider.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{oauthLoading && (
|
||||
<p style={{ margin: '0.5rem 0', fontSize: '0.875rem', color: '#6b7280', textAlign: 'center' }}>
|
||||
{t('setup.login.oauthPending', 'Opening browser for authentication...')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,225 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group, Collapse, Anchor, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
interface LoginFormProps {
|
||||
serverUrl: string;
|
||||
isSaaS?: boolean;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false, onLogin, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!username.trim()) {
|
||||
setValidationError(isSaaS
|
||||
? t('setup.login.error.emptyEmail', 'Please enter your email')
|
||||
: t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(username.trim(), password);
|
||||
};
|
||||
|
||||
const handleOAuthLogin = async (provider: 'google' | 'github') => {
|
||||
// Prevent concurrent OAuth attempts
|
||||
if (oauthLoading || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOauthLoading(true);
|
||||
setValidationError(null);
|
||||
|
||||
// For SaaS, use configured SaaS URL; for self-hosted, derive from serverUrl
|
||||
const authServerUrl = isSaaS
|
||||
? STIRLING_SAAS_URL
|
||||
: serverUrl; // Self-hosted might have its own auth
|
||||
|
||||
// Build callback page HTML with translations and dark mode support
|
||||
const successHtml = buildOAuthCallbackHtml({
|
||||
title: t('oauth.success.title', 'Authentication Successful'),
|
||||
message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'),
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const errorHtml = buildOAuthCallbackHtml({
|
||||
title: t('oauth.error.title', 'Authentication Failed'),
|
||||
message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'),
|
||||
isError: true,
|
||||
errorPlaceholder: true, // {error} will be replaced by Rust
|
||||
});
|
||||
|
||||
const userInfo = await authService.loginWithOAuth(provider, authServerUrl, successHtml, errorHtml);
|
||||
|
||||
// Call the onLogin callback to complete setup (username/password not needed for OAuth)
|
||||
await onLogin(userInfo.username, '');
|
||||
} catch (error) {
|
||||
console.error('OAuth login failed:', error);
|
||||
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.');
|
||||
|
||||
setValidationError(errorMessage);
|
||||
setOauthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
|
||||
</Text>
|
||||
|
||||
{/* Login requirement note for self-hosted servers */}
|
||||
{!isSaaS && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('setup.login.serverRequirement', 'Note: The server must have login enabled.')}{' '}
|
||||
<Anchor
|
||||
size="xs"
|
||||
onClick={() => setShowInstructions(!showInstructions)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{showInstructions
|
||||
? t('setup.login.hideInstructions', 'Hide instructions')
|
||||
: t('setup.login.showInstructions', 'How to enable?')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Collapse in={showInstructions}>
|
||||
<Box mt="xs" p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: '4px' }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('setup.login.instructions', 'To enable login on your Stirling PDF server:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsEnvVar', 'Set the environment variable:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
||||
SECURITY_ENABLELOGIN=true
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsOrYml', 'Or in settings.yml:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
||||
security.enableLogin: true
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsRestart', 'Then restart your server for the changes to take effect.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* OAuth Login Buttons - Only show for SaaS */}
|
||||
{isSaaS && (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<img src={`${BASE_PATH}/Login/google.svg`} alt="Google" width={18} height={18} />}
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
disabled={loading || oauthLoading}
|
||||
styles={{
|
||||
root: { height: '42px' },
|
||||
}}
|
||||
>
|
||||
{t('setup.login.signInWith', 'Sign in with')} Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<img src={`${BASE_PATH}/Login/github.svg`} alt="GitHub" width={18} height={18} />}
|
||||
onClick={() => handleOAuthLogin('github')}
|
||||
disabled={loading || oauthLoading}
|
||||
styles={{
|
||||
root: { height: '42px' },
|
||||
}}
|
||||
>
|
||||
{t('setup.login.signInWith', 'Sign in with')} GitHub
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{oauthLoading && (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('setup.login.oauthPending', 'Opening browser for authentication...')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider label={t('setup.login.orContinueWith', 'Or continue with email')} labelPosition="center" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={isSaaS
|
||||
? t('setup.login.email.label', 'Email')
|
||||
: t('setup.login.username.label', 'Username')}
|
||||
placeholder={isSaaS
|
||||
? t('setup.login.email.placeholder', 'Enter your email')
|
||||
: t('setup.login.username.placeholder', 'Enter your username')}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t('setup.login.password.label', 'Password')}
|
||||
placeholder={t('setup.login.password.placeholder', 'Enter your password')}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
|
||||
{validationError && (
|
||||
<Text c="red" size="sm">
|
||||
{validationError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
mt="md"
|
||||
fullWidth
|
||||
color="#AF3434"
|
||||
>
|
||||
{t('setup.login.submit', 'Login')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Stack, Button, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
|
||||
interface ModeSelectionProps {
|
||||
onSelect: (mode: 'saas' | 'selfhosted') => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('saas')}
|
||||
disabled={loading}
|
||||
leftSection={<CloudIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
section: {
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.saas.title', 'Use SaaS')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.saas.description', 'Sign in to Stirling PDF cloud service')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('selfhosted')}
|
||||
disabled={loading}
|
||||
leftSection={<ComputerIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
section: {
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server with your personal account')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
|
||||
import DividerWithText from '@app/components/shared/DividerWithText';
|
||||
import { DesktopOAuthButtons } from '@app/components/SetupWizard/DesktopOAuthButtons';
|
||||
import { SelfHostedLink } from '@app/components/SetupWizard/SelfHostedLink';
|
||||
import { UserInfo } from '@app/services/authService';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface SaaSLoginScreenProps {
|
||||
serverUrl: string;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onSelfHostedClick: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
serverUrl,
|
||||
onLogin,
|
||||
onOAuthSuccess,
|
||||
onSelfHostedClick,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleEmailPasswordSubmit = async () => {
|
||||
// Validation
|
||||
if (!email.trim()) {
|
||||
setValidationError(t('setup.login.error.emptyEmail', 'Please enter your email'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(email.trim(), password);
|
||||
};
|
||||
|
||||
const handleOAuthError = (errorMessage: string) => {
|
||||
setValidationError(errorMessage);
|
||||
};
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader title={t('setup.saas.title', 'Sign in to Stirling Cloud')} />
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
|
||||
<DesktopOAuthButtons
|
||||
onOAuthSuccess={onOAuthSuccess}
|
||||
onError={handleOAuthError}
|
||||
isDisabled={loading}
|
||||
serverUrl={serverUrl}
|
||||
providers={['google', 'github']}
|
||||
/>
|
||||
|
||||
<DividerWithText
|
||||
text={t('setup.login.orContinueWith', 'Or continue with email')}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={(value) => {
|
||||
setEmail(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
setPassword={(value) => {
|
||||
setPassword(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
onSubmit={handleEmailPasswordSubmit}
|
||||
isSubmitting={loading}
|
||||
submitButtonText={t('setup.login.submit', 'Login')}
|
||||
/>
|
||||
|
||||
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface SelfHostedLinkProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SelfHostedLink: React.FC<SelfHostedLinkProps> = ({ onClick, disabled = false }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="navigation-link-button"
|
||||
>
|
||||
{t('setup.selfhosted.link', 'or connect to a self hosted account')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text } from '@mantine/core';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
|
||||
import DividerWithText from '@app/components/shared/DividerWithText';
|
||||
import { DesktopOAuthButtons, OAuthProvider } from '@app/components/SetupWizard/DesktopOAuthButtons';
|
||||
import { UserInfo } from '@app/services/authService';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface SelfHostedLoginScreenProps {
|
||||
serverUrl: string;
|
||||
enabledOAuthProviders?: string[];
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
serverUrl,
|
||||
enabledOAuthProviders,
|
||||
onLogin,
|
||||
onOAuthSuccess,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if (!username.trim()) {
|
||||
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(username.trim(), password);
|
||||
};
|
||||
|
||||
const handleOAuthError = (errorMessage: string) => {
|
||||
setValidationError(errorMessage);
|
||||
};
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t('setup.selfhosted.title', 'Sign in to Server')}
|
||||
subtitle={t('setup.selfhosted.subtitle', 'Enter your server credentials')}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
|
||||
<Text size="sm" mb="md">
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <Text span fw="500">{serverUrl}</Text>
|
||||
</Text>
|
||||
|
||||
{/* Show OAuth buttons if providers are available */}
|
||||
{enabledOAuthProviders && enabledOAuthProviders.length > 0 && (
|
||||
<>
|
||||
<DesktopOAuthButtons
|
||||
onOAuthSuccess={onOAuthSuccess}
|
||||
onError={handleOAuthError}
|
||||
isDisabled={loading}
|
||||
serverUrl={serverUrl}
|
||||
providers={enabledOAuthProviders as OAuthProvider[]}
|
||||
/>
|
||||
|
||||
<DividerWithText
|
||||
text={t('setup.login.orContinueWith', 'Or continue with email')}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EmailPasswordForm
|
||||
email={username}
|
||||
password={password}
|
||||
setEmail={(value) => {
|
||||
setUsername(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
setPassword={(value) => {
|
||||
setPassword(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={loading}
|
||||
submitButtonText={t('setup.login.submit', 'Login')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Button, TextInput } from '@mantine/core';
|
||||
import { Stack, Button, TextInput, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerConfig } from '@app/services/connectionModeService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface ServerSelectionProps {
|
||||
onSelect: (config: ServerConfig) => void;
|
||||
@@ -14,11 +15,13 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [securityDisabled, setSecurityDisabled] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const url = customUrl.trim();
|
||||
// Normalize URL: trim and remove trailing slashes
|
||||
const url = customUrl.trim().replace(/\/+$/, '');
|
||||
|
||||
if (!url) {
|
||||
setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
|
||||
@@ -28,6 +31,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
// Test connection before proceeding
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
setSecurityDisabled(false);
|
||||
|
||||
try {
|
||||
const isReachable = await connectionModeService.testConnection(url);
|
||||
@@ -38,9 +42,67 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
return;
|
||||
}
|
||||
|
||||
// Connection successful
|
||||
// Fetch OAuth providers and check if login is enabled
|
||||
let enabledProviders: string[] = [];
|
||||
try {
|
||||
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
|
||||
|
||||
// Check if security is disabled (status 403 or error response)
|
||||
if (!response.ok) {
|
||||
if (response.status === 403 || response.status === 401) {
|
||||
setSecurityDisabled(true);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
// Other error statuses - show generic error
|
||||
setTestError(
|
||||
t('setup.server.error.configFetch', 'Failed to fetch server configuration (status {{status}})', {
|
||||
status: response.status
|
||||
})
|
||||
);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Login UI data:', data);
|
||||
|
||||
// Check if the response indicates security is disabled
|
||||
if (data.enableLogin === false || data.securityEnabled === false) {
|
||||
setSecurityDisabled(true);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract provider IDs from authorization URLs
|
||||
// Example: "/oauth2/authorization/google" → "google"
|
||||
enabledProviders = Object.keys(data.providerList || {})
|
||||
.map(key => key.split('/').pop())
|
||||
.filter((id): id is string => id !== undefined);
|
||||
|
||||
console.log('[ServerSelection] Detected OAuth providers:', enabledProviders);
|
||||
} catch (err) {
|
||||
console.error('[ServerSelection] Failed to fetch login configuration', err);
|
||||
|
||||
// Check if it's a security disabled error
|
||||
if (err instanceof Error && (err.message.includes('403') || err.message.includes('401'))) {
|
||||
setSecurityDisabled(true);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For any other error (network, CORS, invalid JSON, etc.), show error and don't proceed
|
||||
setTestError(
|
||||
t('setup.server.error.configFetch', 'Failed to fetch server configuration. Please check the URL and try again.')
|
||||
);
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connection successful - pass URL and OAuth providers
|
||||
onSelect({
|
||||
url,
|
||||
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error);
|
||||
@@ -64,6 +126,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
onChange={(e) => {
|
||||
setCustomUrl(e.target.value);
|
||||
setTestError(null);
|
||||
setSecurityDisabled(false);
|
||||
}}
|
||||
disabled={loading || testing}
|
||||
error={testError}
|
||||
@@ -73,6 +136,28 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
)}
|
||||
/>
|
||||
|
||||
{securityDisabled && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<LocalIcon icon="warning-rounded" width="1.25rem" height="1.25rem" />}
|
||||
title={t('setup.server.error.securityDisabled.title', 'Login Not Enabled')}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{t('setup.server.error.securityDisabled.body', 'This server does not have login enabled. To connect to this server, you must enable authentication:')}
|
||||
</Text>
|
||||
<Text size="sm" component="div">
|
||||
<ol style={{ margin: 0, paddingLeft: '1.5rem' }}>
|
||||
<li>{t('setup.server.error.securityDisabled.step1', 'Set DOCKER_ENABLE_SECURITY=true in your environment')}</li>
|
||||
<li>{t('setup.server.error.securityDisabled.step2', 'Or set security.enableLogin=true in settings.yml')}</li>
|
||||
<li>{t('setup.server.error.securityDisabled.step3', 'Restart the server')}</li>
|
||||
</ol>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={testing || loading}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
|
||||
import { ServerConfig } from '@app/services/connectionModeService';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface ServerSelectionScreenProps {
|
||||
onSelect: (config: ServerConfig) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const ServerSelectionScreen: React.FC<ServerSelectionScreenProps> = ({
|
||||
onSelect,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t('setup.server.title', 'Connect to Server')}
|
||||
subtitle={t('setup.server.subtitle', 'Enter your self-hosted server URL')}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<ServerSelection onSelect={onSelect} loading={loading} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
.setup-container {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg,
|
||||
light-dark(#f5f5f5, #1a1a1a) 0%,
|
||||
light-dark(#e8e8e8, #0d0d0d) 100%
|
||||
);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.setup-wrapper {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
box-shadow: 0 20px 60px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Container, Paper, Stack, Title, Text, Button, Image } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModeSelection } from '@app/components/SetupWizard/ModeSelection';
|
||||
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
|
||||
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
|
||||
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
||||
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
||||
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
|
||||
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
|
||||
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
|
||||
import { authService, UserInfo } from '@app/services/authService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
|
||||
import '@app/components/SetupWizard/SetupWizard.css';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
enum SetupStep {
|
||||
ModeSelection,
|
||||
SaaSLogin,
|
||||
ServerSelection,
|
||||
SelfHostedLogin,
|
||||
@@ -24,25 +22,11 @@ interface SetupWizardProps {
|
||||
|
||||
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const logoPath = useLogoPath();
|
||||
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleModeSelection = (mode: 'saas' | 'selfhosted') => {
|
||||
setError(null);
|
||||
|
||||
if (mode === 'saas') {
|
||||
// For SaaS, go directly to login screen with SaaS URL
|
||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
} else {
|
||||
// For self-hosted, show server selection first
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaaSLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No SaaS server configured');
|
||||
@@ -70,6 +54,32 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaaSLoginOAuth = async (_userInfo: UserInfo) => {
|
||||
if (!serverConfig) {
|
||||
setError('No SaaS server configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// OAuth already completed by authService.loginWithOAuth
|
||||
await connectionModeService.switchToSaaS(serverConfig.url);
|
||||
tauriBackendService.startBackend().catch(console.error);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('SaaS OAuth login completion failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to complete SaaS login');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelfHostedClick = () => {
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
};
|
||||
|
||||
const handleServerSelection = (config: ServerConfig) => {
|
||||
setServerConfig(config);
|
||||
setError(null);
|
||||
@@ -97,120 +107,82 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelfHostedOAuthSuccess = async (_userInfo: UserInfo) => {
|
||||
if (!serverConfig) {
|
||||
setError('No server configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// OAuth already completed by authService.loginWithOAuth
|
||||
await connectionModeService.switchToSelfHosted(serverConfig);
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Self-hosted OAuth login completion failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to complete login');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.SaaSLogin) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setServerConfig(null);
|
||||
} else if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setServerConfig(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepTitle = () => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.welcome', 'Welcome to Stirling PDF');
|
||||
case SetupStep.SaaSLogin:
|
||||
return t('setup.saas.title', 'Sign in to Stirling Cloud');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.title', 'Connect to Server');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.title', 'Sign in to Server');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStepSubtitle = () => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
|
||||
case SetupStep.SaaSLogin:
|
||||
return t('setup.saas.subtitle', 'Sign in with your Stirling account');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.subtitle', 'Enter your server credentials');
|
||||
default:
|
||||
return '';
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="setup-container">
|
||||
<Container size="sm" className="setup-wrapper">
|
||||
<Paper shadow="xl" p="xl" radius="lg" className="setup-card">
|
||||
<Stack gap="lg">
|
||||
{/* Logo Header */}
|
||||
<Stack gap="xs" align="center">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt="Stirling PDF"
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
|
||||
{getStepTitle()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{getStepSubtitle()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<DesktopAuthLayout>
|
||||
{/* Step Content */}
|
||||
{activeStep === SetupStep.SaaSLogin && (
|
||||
<SaaSLoginScreen
|
||||
serverUrl={serverConfig?.url || STIRLING_SAAS_URL}
|
||||
onLogin={handleSaaSLogin}
|
||||
onOAuthSuccess={handleSaaSLoginOAuth}
|
||||
onSelfHostedClick={handleSelfHostedClick}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<Paper p="md" bg="red.0" style={{ border: '1px solid var(--mantine-color-red-3)' }}>
|
||||
<Text size="sm" c="red.7" ta="center">
|
||||
{error}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelectionScreen
|
||||
onSelect={handleServerSelection}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step Content */}
|
||||
{activeStep === SetupStep.ModeSelection && (
|
||||
<ModeSelection onSelect={handleModeSelection} loading={loading} />
|
||||
)}
|
||||
{activeStep === SetupStep.SelfHostedLogin && (
|
||||
<SelfHostedLoginScreen
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
onOAuthSuccess={handleSelfHostedOAuthSuccess}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SaaSLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
isSaaS={true}
|
||||
onLogin={handleSaaSLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelection onSelect={handleServerSelection} loading={loading} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SelfHostedLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
isSaaS={false}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Back Button */}
|
||||
{activeStep > SetupStep.ModeSelection && !loading && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleBack}
|
||||
fullWidth
|
||||
mt="md"
|
||||
>
|
||||
{t('common.back', 'Back')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</div>
|
||||
{/* Back Button */}
|
||||
{activeStep > SetupStep.SaaSLogin && !loading && (
|
||||
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="navigation-link-button"
|
||||
>
|
||||
{t('common.back', 'Back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</DesktopAuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig';
|
||||
const apiClient = create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
responseType: 'json',
|
||||
withCredentials: true,
|
||||
withCredentials: false, // Desktop doesn't need credentials
|
||||
});
|
||||
|
||||
// Setup interceptors (desktop-specific auth and backend ready checks)
|
||||
|
||||
@@ -48,13 +48,21 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Debug logging
|
||||
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||
|
||||
// Add auth token for remote requests
|
||||
// Add auth token for remote requests and enable credentials
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
if (isRemote) {
|
||||
// Self-hosted mode: enable credentials for session management
|
||||
extendedConfig.withCredentials = true;
|
||||
|
||||
const token = await authService.getAuthToken();
|
||||
if (token) {
|
||||
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.warn('[apiClientSetup] Self-hosted mode but no auth token available');
|
||||
}
|
||||
} else {
|
||||
// SaaS mode: disable credentials (security disabled on local backend)
|
||||
extendedConfig.withCredentials = false;
|
||||
}
|
||||
|
||||
// Backend readiness check (for local backend)
|
||||
@@ -85,7 +93,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
// Response interceptor: Handle auth errors
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config as ExtendedRequestConfig;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export class AuthService {
|
||||
private static instance: AuthService;
|
||||
private authStatus: AuthStatus = 'unauthenticated';
|
||||
private userInfo: UserInfo | null = null;
|
||||
private cachedToken: string | null = null;
|
||||
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
||||
|
||||
static getInstance(): AuthService {
|
||||
@@ -38,13 +39,32 @@ export class AuthService {
|
||||
* Save token to all storage locations and notify listeners
|
||||
*/
|
||||
private async saveTokenEverywhere(token: string): Promise<void> {
|
||||
// Save to Tauri store
|
||||
await invoke('save_auth_token', { token });
|
||||
console.log('[Desktop AuthService] Token saved to Tauri store');
|
||||
// Validate token before caching
|
||||
if (!token || token.trim().length === 0) {
|
||||
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
// Sync to localStorage for web layer
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[Desktop AuthService] Token saved to localStorage');
|
||||
try {
|
||||
// Save to Tauri store
|
||||
await invoke('save_auth_token', { token });
|
||||
console.log('[Desktop AuthService] ✅ Token saved to Tauri store');
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] ❌ Failed to save token to Tauri store:', error);
|
||||
// Don't throw - we can still use localStorage
|
||||
}
|
||||
|
||||
try {
|
||||
// Sync to localStorage for web layer
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[Desktop AuthService] ✅ Token saved to localStorage');
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] ❌ Failed to save token to localStorage:', error);
|
||||
}
|
||||
|
||||
// Cache the valid token in memory
|
||||
this.cachedToken = token;
|
||||
console.log('[Desktop AuthService] ✅ Token cached in memory');
|
||||
|
||||
// Notify other parts of the system
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
@@ -56,20 +76,25 @@ export class AuthService {
|
||||
*/
|
||||
private async getTokenFromAnySource(): Promise<string | null> {
|
||||
// Try Tauri store first
|
||||
console.log('[Desktop AuthService] Retrieving token from Tauri store...');
|
||||
const token = await invoke<string | null>('get_auth_token');
|
||||
try {
|
||||
const token = await invoke<string | null>('get_auth_token');
|
||||
|
||||
if (token) {
|
||||
console.log(`[Desktop AuthService] Token found in Tauri store (length: ${token.length})`);
|
||||
return token;
|
||||
if (token) {
|
||||
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
|
||||
return token;
|
||||
}
|
||||
|
||||
console.log('[Desktop AuthService] ℹ️ No token in Tauri store, checking localStorage...');
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] ❌ Failed to read from Tauri store:', error);
|
||||
}
|
||||
|
||||
console.log('[Desktop AuthService] No token in Tauri store');
|
||||
|
||||
// Fallback to localStorage
|
||||
const localStorageToken = localStorage.getItem('stirling_jwt');
|
||||
if (localStorageToken) {
|
||||
console.log('[Desktop AuthService] Token found in localStorage (length:', localStorageToken.length, ')');
|
||||
console.log(`[Desktop AuthService] ✅ Token found in localStorage (length: ${localStorageToken.length})`);
|
||||
} else {
|
||||
console.log('[Desktop AuthService] ❌ No token found in any storage');
|
||||
}
|
||||
|
||||
return localStorageToken;
|
||||
@@ -79,6 +104,10 @@ export class AuthService {
|
||||
* Clear token from all storage locations
|
||||
*/
|
||||
private async clearTokenEverywhere(): Promise<void> {
|
||||
// Invalidate cache
|
||||
this.cachedToken = null;
|
||||
console.log('[Desktop AuthService] Cache invalidated');
|
||||
|
||||
await invoke('clear_auth_token');
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
}
|
||||
@@ -183,7 +212,22 @@ export class AuthService {
|
||||
|
||||
async getAuthToken(): Promise<string | null> {
|
||||
try {
|
||||
return await this.getTokenFromAnySource();
|
||||
// Return cached token if available
|
||||
if (this.cachedToken) {
|
||||
console.debug('[Desktop AuthService] ✅ Returning cached token');
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
console.debug('[Desktop AuthService] Cache miss, fetching from storage...');
|
||||
const token = await this.getTokenFromAnySource();
|
||||
|
||||
// Cache the token if valid
|
||||
if (token && token.trim().length > 0) {
|
||||
this.cachedToken = token;
|
||||
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
|
||||
}
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] Failed to get auth token:', error);
|
||||
return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ export type ConnectionMode = 'saas' | 'selfhosted';
|
||||
|
||||
export interface ServerConfig {
|
||||
url: string;
|
||||
enabledOAuthProviders?: string[];
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
|
||||
@@ -61,7 +61,7 @@ class TauriHttpClient {
|
||||
headers: {},
|
||||
timeout: 120000,
|
||||
responseType: 'json',
|
||||
withCredentials: true,
|
||||
withCredentials: false, // Desktop doesn't need credentials (backend has allowCredentials=false)
|
||||
};
|
||||
|
||||
public interceptors: Interceptors = {
|
||||
@@ -173,14 +173,15 @@ class TauriHttpClient {
|
||||
}
|
||||
|
||||
try {
|
||||
// Debug logging
|
||||
console.debug(`[tauriHttpClient] Fetch request:`, { url, method });
|
||||
// Convert withCredentials to fetch API's credentials option
|
||||
const credentials: RequestCredentials = finalConfig.withCredentials ? 'include' : 'omit';
|
||||
|
||||
// Make the request using Tauri's native HTTP client (standard Fetch API)
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
credentials,
|
||||
});
|
||||
|
||||
// Parse response based on responseType
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Known OAuth providers with dedicated UI support.
|
||||
* Custom providers are also supported - the backend determines availability.
|
||||
*/
|
||||
export const KNOWN_OAUTH_PROVIDERS = [
|
||||
'github',
|
||||
'google',
|
||||
'apple',
|
||||
'azure',
|
||||
'keycloak',
|
||||
'cloudron',
|
||||
'authentik',
|
||||
'oidc',
|
||||
] as const;
|
||||
|
||||
export type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number];
|
||||
|
||||
/**
|
||||
* OAuth provider ID - can be any known provider or custom string.
|
||||
* The backend configuration determines which providers are available.
|
||||
*
|
||||
* @example 'github' | 'google' | 'mycompany' | 'authentik'
|
||||
*/
|
||||
export type OAuthProvider = KnownOAuthProvider | (string & {});
|
||||
@@ -10,6 +10,7 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { AxiosError } from 'axios';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||
|
||||
// Helper to extract error message from axios error
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
@@ -248,11 +249,14 @@ class SpringAuthClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with OAuth provider (GitHub, Google, etc.)
|
||||
* Sign in with OAuth provider (GitHub, Google, Authentik, etc.)
|
||||
* This redirects to the Spring OAuth2 authorization endpoint
|
||||
*
|
||||
* @param params.provider - OAuth provider ID (e.g., 'github', 'google', 'authentik', 'mycompany')
|
||||
* Can be any known provider or custom string - the backend determines available providers
|
||||
*/
|
||||
async signInWithOAuth(params: {
|
||||
provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc';
|
||||
provider: OAuthProvider;
|
||||
options?: { redirectTo?: string; queryParams?: Record<string, any> };
|
||||
}): Promise<{ error: AuthError | null }> {
|
||||
try {
|
||||
|
||||
-19
@@ -13,7 +13,6 @@ import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBann
|
||||
|
||||
interface SecuritySettingsData {
|
||||
enableLogin?: boolean;
|
||||
csrfDisabled?: boolean;
|
||||
loginMethod?: string;
|
||||
loginAttemptCount?: number;
|
||||
loginResetTimeMinutes?: number;
|
||||
@@ -123,7 +122,6 @@ export default function AdminSecuritySection() {
|
||||
const deltaSettings: Record<string, any> = {
|
||||
// Security settings
|
||||
'security.enableLogin': securitySettings.enableLogin,
|
||||
'security.csrfDisabled': securitySettings.csrfDisabled,
|
||||
'security.loginMethod': securitySettings.loginMethod,
|
||||
'security.loginAttemptCount': securitySettings.loginAttemptCount,
|
||||
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
|
||||
@@ -282,23 +280,6 @@ export default function AdminSecuritySection() {
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled.label', 'Disable CSRF Protection')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.csrfDisabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('csrfDisabled')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const PLAN_FEATURES = {
|
||||
{ name: 'Editing text in pdfs', included: false },
|
||||
{ name: 'Users limited to seats', included: false },
|
||||
{ name: 'SSO', included: false },
|
||||
{ name: 'SAML', included: false },
|
||||
{ name: 'Auditing', included: false },
|
||||
{ name: 'Usage tracking', included: false },
|
||||
{ name: 'Prometheus Support', included: false },
|
||||
@@ -37,7 +38,8 @@ export const PLAN_FEATURES = {
|
||||
{ name: 'External Database', included: true },
|
||||
{ name: 'Editing text in pdfs', included: true },
|
||||
{ name: 'Users limited to seats', included: false },
|
||||
{ name: 'SSO', included: false },
|
||||
{ name: 'SSO', included: true },
|
||||
{ name: 'SAML', included: false },
|
||||
{ name: 'Auditing', included: false },
|
||||
{ name: 'Usage tracking', included: false },
|
||||
{ name: 'Prometheus Support', included: false },
|
||||
@@ -57,6 +59,7 @@ export const PLAN_FEATURES = {
|
||||
{ name: 'Editing text in pdfs', included: true },
|
||||
{ name: 'Users limited to seats', included: true },
|
||||
{ name: 'SSO', included: true },
|
||||
{ name: 'SAML', included: true },
|
||||
{ name: 'Auditing', included: true },
|
||||
{ name: 'Usage tracking', included: true },
|
||||
{ name: 'Prometheus Support', included: true },
|
||||
@@ -74,6 +77,7 @@ export const PLAN_HIGHLIGHTS = {
|
||||
'Self-hosted on your infrastructure',
|
||||
'Unlimited users',
|
||||
'Advanced integrations',
|
||||
'SSO (OAuth2/OIDC)',
|
||||
'Editing text in PDFs',
|
||||
'Cancel anytime'
|
||||
],
|
||||
@@ -81,17 +85,18 @@ export const PLAN_HIGHLIGHTS = {
|
||||
'Self-hosted on your infrastructure',
|
||||
'Unlimited users',
|
||||
'Advanced integrations',
|
||||
'SSO (OAuth2/OIDC)',
|
||||
'Editing text in PDFs',
|
||||
'Save with annual billing'
|
||||
],
|
||||
ENTERPRISE_MONTHLY: [
|
||||
'Enterprise features (SSO, Auditing)',
|
||||
'Enterprise features (SAML, Auditing)',
|
||||
'Usage tracking & Prometheus',
|
||||
'Custom PDF metadata',
|
||||
'Per-seat licensing'
|
||||
],
|
||||
ENTERPRISE_YEARLY: [
|
||||
'Enterprise features (SSO, Auditing)',
|
||||
'Enterprise features (SAML, Auditing)',
|
||||
'Usage tracking & Prometheus',
|
||||
'Custom PDF metadata',
|
||||
'Save with annual billing'
|
||||
|
||||
@@ -249,7 +249,7 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
|
||||
}, [fetchUserCounts]);
|
||||
|
||||
const hasPaidLicense = useMemo(() => {
|
||||
return config?.license === 'PRO' || config?.license === 'ENTERPRISE';
|
||||
return config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE';
|
||||
}, [config?.license]);
|
||||
|
||||
const licenseKeyValid = useMemo(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import Login from '@app/routes/Login';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
// Mock i18n to return fallback text
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -36,8 +37,13 @@ vi.mock('@app/hooks/useDocumentMeta', () => ({
|
||||
useDocumentMeta: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetch for provider list
|
||||
global.fetch = vi.fn();
|
||||
// Mock apiClient for provider list
|
||||
vi.mock('@app/services/apiClient', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
const mockBackendProbeState = {
|
||||
@@ -89,14 +95,13 @@ describe('Login', () => {
|
||||
refreshSession: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock fetch for login UI data
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
// Mock apiClient for login UI data
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {},
|
||||
}),
|
||||
} as Response);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should render login form', async () => {
|
||||
@@ -239,6 +244,136 @@ describe('Login', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use actual provider ID for OAuth login (authentik)', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock provider list with authentik
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/authentik': 'Authentik',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Wait for OAuth button to appear
|
||||
await waitFor(() => {
|
||||
const button = screen.queryByText('Authentik');
|
||||
expect(button).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
const oauthButton = screen.getByText('Authentik');
|
||||
await user.click(oauthButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should use 'authentik' directly, NOT map to 'oidc'
|
||||
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
|
||||
provider: 'authentik',
|
||||
options: { redirectTo: '/auth/callback' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should use actual provider ID for OAuth login (custom provider)', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock provider list with custom provider 'mycompany'
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/mycompany': 'My Company SSO',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Wait for OAuth button to appear (will show 'Mycompany' as label)
|
||||
await waitFor(() => {
|
||||
const button = screen.queryByText('Mycompany');
|
||||
expect(button).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
const oauthButton = screen.getByText('Mycompany');
|
||||
await user.click(oauthButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should use 'mycompany' directly - this is the critical fix
|
||||
// Previously it would map unknown providers to 'oidc'
|
||||
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
|
||||
provider: 'mycompany',
|
||||
options: { redirectTo: '/auth/callback' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should use oidc provider ID when explicitly configured', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock provider list with 'oidc'
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/oidc': 'OIDC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Wait for OAuth button to appear
|
||||
await waitFor(() => {
|
||||
const button = screen.queryByText('OIDC');
|
||||
expect(button).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
const oauthButton = screen.getByText('OIDC');
|
||||
await user.click(oauthButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should use 'oidc' when explicitly configured
|
||||
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
|
||||
provider: 'oidc',
|
||||
options: { redirectTo: '/auth/callback' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error on failed login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const errorMessage = 'Invalid credentials';
|
||||
@@ -359,13 +494,12 @@ describe('Login', () => {
|
||||
it('should redirect to home when login disabled', async () => {
|
||||
mockBackendProbeState.loginDisabled = true;
|
||||
mockProbe.mockResolvedValueOnce({ status: 'up', loginDisabled: true, loading: false });
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: {
|
||||
enableLogin: false,
|
||||
providerList: {},
|
||||
}),
|
||||
} as Response);
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
@@ -381,15 +515,14 @@ describe('Login', () => {
|
||||
});
|
||||
|
||||
it('should handle OAuth provider click', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/github': 'GitHub',
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||
error: null,
|
||||
@@ -416,13 +549,12 @@ describe('Login', () => {
|
||||
});
|
||||
|
||||
it('should show email form by default when no SSO providers', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {}, // No providers
|
||||
}),
|
||||
} as Response);
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
|
||||
@@ -10,6 +10,7 @@ import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||
import { useBackendProbe } from '@app/hooks/useBackendProbe';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||
|
||||
// Import login components
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
@@ -31,7 +32,7 @@ export default function Login() {
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [email, setEmail] = useState(() => searchParams.get('email') ?? '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
|
||||
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
|
||||
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
||||
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
|
||||
const backendProbe = useBackendProbe();
|
||||
@@ -226,25 +227,17 @@ export default function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
// Known OAuth providers that have dedicated backend support
|
||||
const KNOWN_OAUTH_PROVIDERS = ['github', 'google', 'apple', 'azure', 'keycloak', 'oidc'] as const;
|
||||
type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number];
|
||||
|
||||
const signInWithProvider = async (provider: string) => {
|
||||
const signInWithProvider = async (provider: OAuthProvider) => {
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
|
||||
// Map unknown providers to 'oidc' for the backend redirect
|
||||
const backendProvider: KnownOAuthProvider = KNOWN_OAUTH_PROVIDERS.includes(provider as KnownOAuthProvider)
|
||||
? (provider as KnownOAuthProvider)
|
||||
: 'oidc';
|
||||
console.log(`[Login] Signing in with provider: ${provider}`);
|
||||
|
||||
console.log(`[Login] Signing in with ${provider} (backend: ${backendProvider})`);
|
||||
|
||||
// Redirect to Spring OAuth2 endpoint
|
||||
// Redirect to Spring OAuth2 endpoint using the actual provider ID from backend
|
||||
// The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak')
|
||||
const { error } = await springAuth.signInWithOAuth({
|
||||
provider: backendProvider,
|
||||
provider: provider,
|
||||
options: { redirectTo: `${BASE_PATH}/auth/callback` }
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import OAuthButtons from '@app/routes/login/OAuthButtons';
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('OAuthButtons', () => {
|
||||
const mockOnProviderClick = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render known providers with correct labels', () => {
|
||||
const enabledProviders = ['google', 'github', 'authentik'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Check that known providers are rendered with their labels
|
||||
expect(screen.getByText('Google')).toBeTruthy();
|
||||
expect(screen.getByText('GitHub')).toBeTruthy();
|
||||
expect(screen.getByText('Authentik')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render unknown provider with capitalized label and generic icon', () => {
|
||||
const enabledProviders = ['mycompany'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Unknown provider should be capitalized
|
||||
expect(screen.getByText('Mycompany')).toBeTruthy();
|
||||
|
||||
// Check that button has generic OIDC icon
|
||||
const button = screen.getByText('Mycompany').closest('button');
|
||||
expect(button).toBeTruthy();
|
||||
const img = button?.querySelector('img');
|
||||
expect(img?.src).toContain('oidc.svg');
|
||||
});
|
||||
|
||||
it('should call onProviderClick with actual provider ID (not "oidc")', async () => {
|
||||
const user = userEvent.setup();
|
||||
const enabledProviders = ['mycompany'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const button = screen.getByText('Mycompany');
|
||||
await user.click(button);
|
||||
|
||||
// Should use actual provider ID 'mycompany', NOT 'oidc'
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('mycompany');
|
||||
});
|
||||
|
||||
it('should call onProviderClick with "authentik" when authentik is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const enabledProviders = ['authentik'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const button = screen.getByText('Authentik');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('authentik');
|
||||
});
|
||||
|
||||
it('should call onProviderClick with "oidc" when OIDC is explicitly configured', async () => {
|
||||
const user = userEvent.setup();
|
||||
const enabledProviders = ['oidc'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const button = screen.getByText('OIDC');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('oidc');
|
||||
});
|
||||
|
||||
it('should disable buttons when isSubmitting is true', () => {
|
||||
const enabledProviders = ['google', 'github'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={true}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const googleButton = screen.getByText('Google').closest('button') as HTMLButtonElement;
|
||||
const githubButton = screen.getByText('GitHub').closest('button') as HTMLButtonElement;
|
||||
|
||||
expect(googleButton.disabled).toBe(true);
|
||||
expect(githubButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should render nothing when no providers are enabled', () => {
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={[]}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should render null/nothing (excluding Mantine's style tags)
|
||||
const hasContent = Array.from(container.children).some(
|
||||
child => child.tagName.toLowerCase() !== 'style'
|
||||
);
|
||||
expect(hasContent).toBe(false);
|
||||
});
|
||||
|
||||
it('should render multiple unknown providers with correct IDs', async () => {
|
||||
const user = userEvent.setup();
|
||||
const enabledProviders = ['company1', 'company2', 'company3'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// All should be capitalized
|
||||
expect(screen.getByText('Company1')).toBeTruthy();
|
||||
expect(screen.getByText('Company2')).toBeTruthy();
|
||||
expect(screen.getByText('Company3')).toBeTruthy();
|
||||
|
||||
// Click each and verify correct ID is passed
|
||||
await user.click(screen.getByText('Company1'));
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('company1');
|
||||
|
||||
await user.click(screen.getByText('Company2'));
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('company2');
|
||||
|
||||
await user.click(screen.getByText('Company3'));
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('company3');
|
||||
});
|
||||
|
||||
it('should use correct icon for known providers', () => {
|
||||
const enabledProviders = ['google', 'github', 'authentik', 'keycloak'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Check that each known provider has its specific icon
|
||||
const googleButton = screen.getByText('Google').closest('button');
|
||||
expect(googleButton?.querySelector('img')?.src).toContain('google.svg');
|
||||
|
||||
const githubButton = screen.getByText('GitHub').closest('button');
|
||||
expect(githubButton?.querySelector('img')?.src).toContain('github.svg');
|
||||
|
||||
const authentikButton = screen.getByText('Authentik').closest('button');
|
||||
expect(authentikButton?.querySelector('img')?.src).toContain('authentik.svg');
|
||||
|
||||
const keycloakButton = screen.getByText('Keycloak').closest('button');
|
||||
expect(keycloakButton?.querySelector('img')?.src).toContain('keycloak.svg');
|
||||
});
|
||||
|
||||
it('should handle mixed known and unknown providers', async () => {
|
||||
const user = userEvent.setup();
|
||||
const enabledProviders = ['google', 'mycompany', 'authentik', 'custom'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Known providers with correct labels
|
||||
expect(screen.getByText('Google')).toBeTruthy();
|
||||
expect(screen.getByText('Authentik')).toBeTruthy();
|
||||
|
||||
// Unknown providers with capitalized labels
|
||||
expect(screen.getByText('Mycompany')).toBeTruthy();
|
||||
expect(screen.getByText('Custom')).toBeTruthy();
|
||||
|
||||
// Click each and verify IDs are preserved
|
||||
await user.click(screen.getByText('Google'));
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('google');
|
||||
|
||||
await user.click(screen.getByText('Mycompany'));
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('mycompany');
|
||||
|
||||
await user.click(screen.getByText('Authentik'));
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('authentik');
|
||||
|
||||
await user.click(screen.getByText('Custom'));
|
||||
expect(mockOnProviderClick).toHaveBeenCalledWith('custom');
|
||||
});
|
||||
|
||||
it('should maintain provider ID consistency - critical for OAuth redirect', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// This test ensures the fix for GitHub issue #5141
|
||||
// The provider ID used in the button click MUST match the backend registration ID
|
||||
// Previously, unknown providers were mapped to 'oidc', breaking the OAuth flow
|
||||
|
||||
const enabledProviders = ['authentik', 'okta', 'auth0'];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<OAuthButtons
|
||||
onProviderClick={mockOnProviderClick}
|
||||
isSubmitting={false}
|
||||
enabledProviders={enabledProviders}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Each provider should use its actual ID, not 'oidc'
|
||||
await user.click(screen.getByText('Authentik'));
|
||||
expect(mockOnProviderClick).toHaveBeenLastCalledWith('authentik');
|
||||
|
||||
await user.click(screen.getByText('Okta'));
|
||||
expect(mockOnProviderClick).toHaveBeenLastCalledWith('okta');
|
||||
|
||||
await user.click(screen.getByText('Auth0'));
|
||||
expect(mockOnProviderClick).toHaveBeenLastCalledWith('auth0');
|
||||
|
||||
// Verify none were called with 'oidc' instead of their actual ID
|
||||
expect(mockOnProviderClick).not.toHaveBeenCalledWith('oidc');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||
|
||||
// Debug flag to show all providers for UI testing
|
||||
// Set to true to see all SSO options regardless of backend configuration
|
||||
@@ -22,10 +23,10 @@ export const oauthProviderConfig: Record<string, { label: string; file: string }
|
||||
const GENERIC_PROVIDER_ICON = 'oidc.svg';
|
||||
|
||||
interface OAuthButtonsProps {
|
||||
onProviderClick: (provider: string) => void
|
||||
onProviderClick: (provider: OAuthProvider) => void
|
||||
isSubmitting: boolean
|
||||
layout?: 'vertical' | 'grid' | 'icons'
|
||||
enabledProviders?: string[] // List of enabled provider IDs from backend
|
||||
enabledProviders?: OAuthProvider[] // List of enabled provider IDs from backend
|
||||
}
|
||||
|
||||
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) {
|
||||
|
||||
Reference in New Issue
Block a user