Compare commits

...
Author SHA1 Message Date
Anthony StirlingandGitHub 20a6a1c954 Merge branch 'V2' into codex/add-remember-previous-inputs-functionality 2025-11-17 20:39:59 +00:00
James BruntonandGitHub a415c457e9 Add prompt to make Stirling your default PDF app (#4890)
# Description of Changes
- Adds a reusable banner component/system to the core app
- Adds banner at the top of the desktop app if Stirling isn't your
default PDF editor, with a button to make it your default
- Adds a permanent button in the settings to do it manually (in case
you've dismissed the banner)
- Simplifies the file loading logic to fix a bug where the input file
could be duplicated occasionally. Now, the TS just receives files from
one buffer, regardless of how they've been passed to the app in Rust.

## Caveats
I've only been able to get the setting of default apps working properly
on Mac. The Windows build isn't signed (yet) so we can't use the proper
API for it, so currently it just sends you to the Settings UI. I've also
not been able to test it on Linux at all.
2025-11-17 16:05:33 +00:00
Anthony StirlingandGitHub 28eb9baa02 Enable withCredentials for API client (#4921)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-17 14:07:58 +00:00
Anthony StirlingandGitHub 4b43693e29 csrf fixes (#4915)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-17 12:05:03 +00:00
Anthony Stirling 2c84587387 feat: add tool input persistence option 2025-11-16 23:50:38 +00:00
EthanHealy01andGitHub 5c9e590856 styling changes (#4894)
# Description of Changes

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-14 14:52:32 +00:00
James BruntonandGitHub be78c72887 Only build Tauri for things that touch desktop specifically, or main/V2 (#4897)
# Description of Changes
Only build Tauri for things that touch desktop specifically, or main/V2
2025-11-14 13:41:07 +00:00
Anthony StirlingandGitHub 1117ce6164 Settings display demo and login fix (#4884)
# Description of Changes
<img width="1569" height="980" alt="image"
src="https://github.com/user-attachments/assets/dca1c227-ed84-4393-97a1-e3ce6eb1620b"
/>

<img width="1596" height="935" alt="image"
src="https://github.com/user-attachments/assets/2003e1be-034a-4cbb-869e-6d5d912ab61d"
/>

<img width="1543" height="997" alt="image"
src="https://github.com/user-attachments/assets/fe0c4f4b-eeee-4db4-a041-e554f350255a"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-14 13:02:45 +00:00
121 changed files with 3483 additions and 1529 deletions
+74
View File
@@ -0,0 +1,74 @@
# Node modules and build artifacts
node_modules
frontend/node_modules
frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
# Gradle build artifacts
.gradle
build
bin
target
out
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.iml
*.iws
*.ipr
# Logs
*.log
logs
# Environment files
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# Java compiled files
*.class
*.jar
*.war
*.ear
# Test reports
test-results
coverage
# Docker
docker-compose.override.yml
.dockerignore
# Temporary files
tmp
temp
*.tmp
*.swp
*~
# Runtime database and config files (locked by running app)
app/core/configs/**
stirling/**
stirling-pdf-DB*.mv.db
stirling-pdf-DB*.trace.db
# Documentation
*.md
!README.md
docs
# CI/CD
.github
.gitlab-ci.yml
+3 -10
View File
@@ -17,18 +17,11 @@ on:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- 'frontend/src/desktop/**'
- 'frontend/tsconfig.desktop.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
permissions:
contents: read
@@ -344,4 +337,4 @@ jobs:
echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues."
exit 1
fi
fi
@@ -0,0 +1,37 @@
package stirling.software.common.annotations.api;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* Combined annotation for Invite management controllers.
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/invite")
@Tag(
name = "Invite",
description =
"""
Invite-link generation and acceptance endpoints for onboarding new users.
Provides the ability to issue invitation tokens, send optional email invites,
validate and accept invite links, and manage pending invitations for teams.
Typical use cases include:
• Admin workflows for issuing time-limited invitations to external users
• Self-service invite acceptance and team assignment
• License limit enforcement when provisioning new accounts
Target users: administrators and automation scripts orchestrating user onboarding.
""")
public @interface InviteApi {}
@@ -505,10 +505,19 @@ public class ApplicationProperties {
public static class Ui {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
}
public String getLogoStyle() {
// Validate and return either "modern" or "classic"
if ("modern".equalsIgnoreCase(logoStyle)) {
return "modern";
}
return "classic"; // default
}
}
@Data
@@ -75,6 +75,9 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
@@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
@@ -20,6 +22,7 @@ import stirling.software.common.service.UserServiceInterface;
@ConfigApi
@Hidden
@Slf4j
public class ConfigController {
private final ApplicationProperties applicationProperties;
@@ -59,9 +62,15 @@ public class ConfigController {
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
// Security settings
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
boolean enableLogin =
applicationProperties.getSecurity().getEnableLogin() && userService != null;
configData.put("enableLogin", enableLogin);
// Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
@@ -176,6 +176,7 @@ system:
ui:
appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
endpoints:
@@ -116,6 +116,10 @@ public class ProprietaryUIDataController {
LoginData data = new LoginData();
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
// Add enableLogin flag so frontend doesn't need to call /app-config
data.setEnableLogin(securityProps.getEnableLogin());
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
@@ -448,6 +452,7 @@ public class ProprietaryUIDataController {
@Data
public static class LoginData {
private Boolean enableLogin;
private Map<String, String> providerList;
private String loginMethod;
private boolean altLogin;
@@ -129,61 +129,53 @@ public class SecurityConfiguration {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// Read CORS allowed origins from settings
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
List<String> configuredOrigins = null;
if (applicationProperties.getSystem() != null) {
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
}
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
CorsConfiguration cfg = new CorsConfiguration();
// Use setAllowedOriginPatterns for better wildcard and port support
cfg.setAllowedOriginPatterns(allowedOrigins);
CorsConfiguration cfg = new CorsConfiguration();
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
cfg.setAllowedOriginPatterns(configuredOrigins);
log.debug(
"CORS configured with allowed origin patterns from settings.yml: {}",
allowedOrigins);
// Set allowed methods explicitly (including OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// Set allowed headers explicitly
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN"));
// Set exposed headers (headers that the browser can access)
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
// Allow credentials (cookies, authorization headers)
cfg.setAllowCredentials(true);
// Set max age for preflight cache
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
configuredOrigins);
} else {
// No CORS origins configured - return null to disable CORS processing entirely
// This avoids empty CORS policy that unexpectedly rejects preflights
// Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*"));
log.info(
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
return null;
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
}
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.annotations.api.InviteApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@@ -26,11 +26,9 @@ import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@UserApi
@InviteApi
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/invite")
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;
@@ -223,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout");
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
}
private enum UserLoginType {
+1 -1
View File
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '1.4.0'
version = '2.0.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+3
View File
@@ -1,3 +1,6 @@
# Run nginx as non-root user
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+53 -4
View File
@@ -41,6 +41,30 @@
"cancel": "Cancel",
"confirm": "Extract"
},
"defaultApp": {
"title": "Set as Default PDF App",
"message": "Would you like to set Stirling PDF as your default PDF editor?",
"description": "You can change this later in your system settings.",
"notNow": "Not Now",
"setDefault": "Set Default",
"dismiss": "Dismiss",
"prompt": {
"title": "Set as Default PDF Editor",
"message": "Make Stirling PDF your default application for opening PDF files."
},
"success": {
"title": "Default App Set",
"message": "Stirling PDF is now your default PDF editor"
},
"settingsOpened": {
"title": "Settings Opened",
"message": "Please select Stirling PDF in your system settings"
},
"error": {
"title": "Error",
"message": "Failed to set default PDF handler"
}
},
"language": {
"direction": "ltr"
},
@@ -329,10 +353,18 @@
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
"defaultToolPickerMode": "Default tool picker mode",
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
"rememberInputs": "Remember tool inputs",
"rememberInputsDescription": "Store the last settings you used for each tool on this device",
"mode": {
"fullscreen": "Fullscreen",
"sidebar": "Sidebar"
}
},
"defaultPdfEditor": "Default PDF editor",
"defaultPdfEditorActive": "Stirling PDF is your default PDF editor",
"defaultPdfEditorInactive": "Another application is set as default",
"defaultPdfEditorChecking": "Checking...",
"defaultPdfEditorSet": "Already Default",
"setAsDefault": "Set as Default"
},
"hotkeys": {
"title": "Keyboard Shortcuts",
@@ -3177,6 +3209,7 @@
"rememberme": "Remember me",
"invalid": "Invalid username or password.",
"locked": "Your account has been locked.",
"sessionExpired": "Your session has expired. Please sign in again.",
"signinTitle": "Please sign in",
"ssoSignIn": "Login via Single Sign-on",
"oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled",
@@ -3654,8 +3687,10 @@
"help": "Help",
"account": "Account",
"config": "Config",
"settings": "Settings",
"adminSettings": "Admin Settings",
"allTools": "All Tools",
"allTools": "Tools",
"reader": "Reader",
"helpMenu": {
"toolsTour": "Tools Tour",
"toolsTourDesc": "Learn what the tools can do",
@@ -3681,6 +3716,12 @@
"saveSuccess": "Settings saved successfully",
"save": "Save Changes",
"restartRequired": "Restart Required",
"loginRequired": "Login mode must be enabled to modify admin settings",
"loginDisabled": {
"title": "Login Mode Required",
"message": "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.",
"readOnly": "The settings below show example values for reference. Enable login mode to view and edit actual configuration."
},
"restart": {
"title": "Restart Required",
"message": "Settings have been saved successfully. A server restart is required for the changes to take effect.",
@@ -3751,6 +3792,12 @@
"description": "Default producer for PDF metadata"
}
},
"logoStyle": {
"label": "Logo Style",
"description": "Choose between the modern minimalist logo or the classic S icon",
"classic": "Classic",
"modern": "Modern"
},
"customPaths": {
"label": "Custom Paths",
"description": "Configure custom file system paths for pipeline processing and external tools",
@@ -4784,7 +4831,7 @@
"maybeLater": "Maybe Later",
"dontShowAgain": "Don't Show Again"
},
"allTools": "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.",
"allTools": "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
"filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
@@ -4844,7 +4891,8 @@
"admin": "Admin",
"roleDescriptions": {
"admin": "Can manage settings and invite members, with full administrative access.",
"member": "Can view and edit shared files, but cannot manage workspace settings or members."
"member": "Can view and edit shared files, but cannot manage workspace settings or members.",
"user": "User"
},
"editRole": "Edit Role",
"enable": "Enable",
@@ -4925,6 +4973,7 @@
"copied": "Link copied to clipboard",
"success": "Invite link generated successfully",
"successWithEmail": "Invite link generated and sent via email",
"emailSent": "Invite link generated and sent via email",
"emailFailed": "Invite link generated, but email failed",
"emailFailedDetails": "Error: {0}. Please share the invite link manually.",
"error": "Failed to generate invite link",
@@ -323,6 +323,8 @@
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
"defaultToolPickerMode": "Default tool picker mode",
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
"rememberInputs": "Remember tool inputs",
"rememberInputsDescription": "Store the last settings you used for each tool on this device",
"mode": {
"fullscreen": "Fullscreen",
"sidebar": "Sidebar"
@@ -3967,7 +3969,7 @@
"account": "Account",
"config": "Config",
"adminSettings": "Admin Settings",
"allTools": "All Tools"
"allTools": "Tools"
},
"admin": {
"error": "Error",
+11
View File
@@ -643,6 +643,15 @@ dependencies = [
"libc",
]
[[package]]
name = "core-services"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0aa845ab21b847ee46954be761815f18f16469b29ef3ba250241b1b8bab659a"
dependencies = [
"core-foundation 0.10.1",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -3941,6 +3950,8 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
name = "stirling-pdf"
version = "0.1.0"
dependencies = [
"core-foundation 0.10.1",
"core-services",
"log",
"reqwest 0.11.27",
"serde",
+4
View File
@@ -31,3 +31,7 @@ tauri-plugin-fs = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-services = "1.0"
@@ -0,0 +1,217 @@
use crate::utils::add_log;
#[cfg(any(target_os = "windows", target_os = "linux"))]
use std::process::Command;
/// Check if Stirling PDF is the default PDF handler
#[tauri::command]
pub fn is_default_pdf_handler() -> Result<bool, String> {
add_log("🔍 Checking if app is default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
check_default_windows()
}
#[cfg(target_os = "macos")]
{
check_default_macos()
}
#[cfg(target_os = "linux")]
{
check_default_linux()
}
}
/// Attempt to set/prompt for Stirling PDF as default PDF handler
#[tauri::command]
pub fn set_as_default_pdf_handler() -> Result<String, String> {
add_log("⚙️ Attempting to set as default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
set_default_windows()
}
#[cfg(target_os = "macos")]
{
set_default_macos()
}
#[cfg(target_os = "linux")]
{
set_default_linux()
}
}
// ============================================================================
// Windows Implementation
// ============================================================================
#[cfg(target_os = "windows")]
fn check_default_windows() -> Result<bool, String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
// Query the default handler for .pdf extension
let output = Command::new("cmd")
.args(["/C", "assoc .pdf"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let assoc = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows PDF association: {}", assoc.trim()));
// Get the ProgID for .pdf files
if let Some(prog_id) = assoc.trim().strip_prefix(".pdf=") {
// Query what application handles this ProgID
let output = Command::new("cmd")
.args(["/C", &format!("ftype {}", prog_id)])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to query file type: {}", e))?;
let ftype = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows file type: {}", ftype.trim()));
// Check if it contains "Stirling" or our app name
let is_default = ftype.to_lowercase().contains("stirling");
Ok(is_default)
} else {
Ok(false)
}
}
#[cfg(target_os = "windows")]
fn set_default_windows() -> Result<String, String> {
// On Windows 10+, we need to open the Default Apps settings
// as programmatic setting requires a signed installer
Command::new("cmd")
.args(["/C", "start", "ms-settings:defaultapps"])
.spawn()
.map_err(|e| format!("Failed to open default apps settings: {}", e))?;
add_log("Opened Windows Default Apps settings".to_string());
Ok("opened_settings".to_string())
}
// ============================================================================
// macOS Implementation (using LaunchServices framework)
// ============================================================================
#[cfg(target_os = "macos")]
fn check_default_macos() -> Result<bool, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSCopyDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSCopyDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
) -> CFStringRef;
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier)
let pdf_uti = CFString::new("com.adobe.pdf");
let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL);
if handler_ref.is_null() {
add_log("No default PDF handler found".to_string());
return Ok(false);
}
let handler = CFString::wrap_under_create_rule(handler_ref);
let handler_str = handler.to_string();
add_log(format!("macOS PDF handler: {}", handler_str));
// Check if it's our bundle identifier
let is_default = handler_str == "stirling.pdf.dev";
Ok(is_default)
}
}
#[cfg(target_os = "macos")]
fn set_default_macos() -> Result<String, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSSetDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSSetDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
handler_bundle_id: CFStringRef,
) -> c_int; // OSStatus
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Set our app as the default handler for PDF files
let pdf_uti = CFString::new("com.adobe.pdf");
let our_bundle_id = CFString::new("stirling.pdf.dev");
let status = LSSetDefaultRoleHandlerForContentType(
pdf_uti.as_concrete_TypeRef(),
K_LS_ROLES_ALL,
our_bundle_id.as_concrete_TypeRef(),
);
if status == 0 {
add_log("Successfully triggered default app dialog".to_string());
Ok("set_successfully".to_string())
} else {
let error_msg = format!("LaunchServices returned status: {}", status);
add_log(error_msg.clone());
Err(error_msg)
}
}
}
// ============================================================================
// Linux Implementation
// ============================================================================
#[cfg(target_os = "linux")]
fn check_default_linux() -> Result<bool, String> {
// Use xdg-mime to check the default application for PDF files
let output = Command::new("xdg-mime")
.args(["query", "default", "application/pdf"])
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let handler = String::from_utf8_lossy(&output.stdout);
add_log(format!("Linux PDF handler: {}", handler.trim()));
// Check if it's our .desktop file
let is_default = handler.trim() == "stirling-pdf.desktop";
Ok(is_default)
}
#[cfg(target_os = "linux")]
fn set_default_linux() -> Result<String, String> {
// Use xdg-mime to set the default application for PDF files
let result = Command::new("xdg-mime")
.args(["default", "stirling-pdf.desktop", "application/pdf"])
.output()
.map_err(|e| format!("Failed to set default app: {}", e))?;
if result.status.success() {
add_log("Set as default PDF handler on Linux".to_string());
Ok("set_successfully".to_string())
} else {
let error = String::from_utf8_lossy(&result.stderr);
add_log(format!("Failed to set default: {}", error));
Err(format!("Failed to set as default: {}", error))
}
}
+5 -17
View File
@@ -14,23 +14,11 @@ pub fn add_opened_file(file_path: String) {
// Command to get opened file paths (if app was launched with files)
#[tauri::command]
pub async fn get_opened_files() -> Result<Vec<String>, String> {
let mut all_files: Vec<String> = Vec::new();
// Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour)
let args: Vec<String> = std::env::args().collect();
let pdf_files: Vec<String> = args.iter()
.skip(1)
.filter(|arg| std::path::Path::new(arg).exists())
.cloned()
.collect();
all_files.extend(pdf_files);
// Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files)
{
let opened_files = OPENED_FILES.lock().unwrap();
all_files.extend(opened_files.clone());
}
// Get all files from the OPENED_FILES store
// Command line args are processed in setup() callback and added to this store
// Additional files from second instances or events are also added here
let opened_files = OPENED_FILES.lock().unwrap();
let all_files = opened_files.clone();
add_log(format!("📂 Returning {} opened file(s)", all_files.len()));
Ok(all_files)
+2
View File
@@ -1,7 +1,9 @@
pub mod backend;
pub mod health;
pub mod files;
pub mod default_app;
pub use backend::{start_backend, cleanup_backend};
pub use health::check_backend_health;
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
+38 -7
View File
@@ -3,7 +3,16 @@ use tauri::{RunEvent, WindowEvent, Emitter, Manager};
mod utils;
mod commands;
use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file};
use commands::{
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
cleanup_backend,
add_opened_file,
is_default_pdf_handler,
set_as_default_pdf_handler,
};
use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -23,9 +32,6 @@ pub fn run() {
// Store file for later retrieval (in case frontend isn't ready yet)
add_opened_file(arg.clone());
// Also emit event for immediate handling if frontend is ready
let _ = app.emit("file-opened", arg.clone());
// Bring the existing window to front
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
@@ -33,13 +39,34 @@ pub fn run() {
}
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
let _ = app.emit("files-changed", ());
}))
.setup(|_app| {
add_log("🚀 Tauri app setup started".to_string());
// Process command line arguments on first launch
let args: Vec<String> = std::env::args().collect();
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Initial file from command line: {}", arg));
add_opened_file(arg.clone());
}
}
add_log("🔍 DEBUG: Setup completed".to_string());
Ok(())
})
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs])
.invoke_handler(tauri::generate_handler![
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
get_tauri_logs,
is_default_pdf_handler,
set_as_default_pdf_handler,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
@@ -58,6 +85,7 @@ pub fn run() {
#[cfg(target_os = "macos")]
RunEvent::Opened { urls } => {
add_log(format!("📂 Tauri file opened event: {:?}", urls));
let mut added_files = false;
for url in urls {
let url_str = url.as_str();
if url_str.starts_with("file://") {
@@ -65,11 +93,14 @@ pub fn run() {
if file_path.ends_with(".pdf") {
add_log(format!("📂 Processing opened PDF: {}", file_path));
add_opened_file(file_path.to_string());
// Use unified event name for consistency across platforms
let _ = app_handle.emit("file-opened", file_path.to_string());
added_files = true;
}
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
if added_files {
let _ = app_handle.emit("files-changed", ());
}
}
_ => {
// Only log unhandled events in debug mode to reduce noise
+5 -2
View File
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import HomePage from "@app/pages/HomePage";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
@@ -16,8 +17,10 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<HomePage />
<OnboardingTour />
<AppLayout>
<HomePage />
<OnboardingTour />
</AppLayout>
</AppProviders>
</Suspense>
);
@@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
interface AppLayoutProps {
children: ReactNode;
}
/**
* App layout wrapper that handles banner rendering and viewport sizing
* Automatically adjusts child components to fit remaining space after banner
*/
export function AppLayout({ children }: AppLayoutProps) {
const { banner } = useBanner();
return (
<>
<style>{`
.h-screen,
.right-rail {
height: 100% !important;
}
`}</style>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
{children}
</div>
</div>
</>
);
}
+34 -31
View File
@@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
@@ -50,42 +51,44 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<BannerProvider>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</BannerProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</PreferencesProvider>
@@ -123,7 +123,7 @@ export default function OnboardingTour() {
const stepsConfig: Record<TourStep, StepType> = {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
padding: 0,
action: () => {
@@ -35,20 +35,20 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
const iconNode = (
<span className="iconContainer">
<AppsIcon sx={{ fontSize: '2rem' }} />
<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />
</span>
);
return (
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<Tooltip content={t("quickAccess.allTools", "Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon
component="a"
href={navProps.href}
onClick={handleNavClick}
size={'lg'}
size={isActive ? 'lg' : 'md'}
variant="subtle"
aria-label={t("quickAccess.allTools", "All Tools")}
aria-label={t("quickAccess.allTools", "Tools")}
style={{
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
@@ -61,7 +61,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
{iconNode}
</ActionIcon>
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
{t("quickAccess.allTools", "All Tools")}
{t("quickAccess.allTools", "Tools")}
</span>
</div>
</Tooltip>
@@ -143,16 +143,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
<div
key={item.key}
onClick={() => {
if (!isDisabled) {
setActive(item.key);
navigate(`/settings/${item.key}`);
}
// Allow navigation even when disabled - the content inside will be disabled
setActive(item.key);
navigate(`/settings/${item.key}`);
}}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.6 : 1,
cursor: 'pointer',
}}
data-tour={`admin-${item.key}-nav`}
>
@@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
setLoading(true);
setError('');
await accountService.changePassword(currentPassword, newPassword);
await accountService.changePasswordOnLogin(currentPassword, newPassword);
alert({
alertType: 'success',
@@ -0,0 +1,72 @@
import React from 'react';
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
interface InfoBannerProps {
icon: string;
message: string;
buttonText: string;
buttonIcon?: string;
onButtonClick: () => void;
onDismiss: () => void;
loading?: boolean;
show?: boolean;
}
/**
* Generic info banner component for displaying dismissible messages at the top of the app
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
onButtonClick,
onDismiss,
loading = false,
show = true,
}) => {
if (!show) {
return null;
}
return (
<Paper
p="sm"
radius={0}
style={{
background: 'var(--mantine-color-blue-0)',
borderBottom: '1px solid var(--mantine-color-blue-2)',
position: 'relative',
}}
>
<Group gap="sm" align="center" wrap="nowrap">
<LocalIcon icon={icon} width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
{message}
</Text>
<Button
variant="light"
color="blue"
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
style={{ flexShrink: 0 }}
>
{buttonText}
</Button>
</Group>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={onDismiss}
aria-label="Dismiss"
style={{ position: 'absolute', top: '50%', right: '0.5rem', transform: 'translateY(-50%)' }}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
</Paper>
);
};
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -6,6 +6,8 @@ import { useTranslation } from 'react-i18next';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
import { useFileManager } from '@app/hooks/useFileManager';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -14,6 +16,9 @@ const LandingPage = () => {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false);
const logoPath = useLogoPath();
const { loadRecentFiles } = useFileManager();
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
const handleFileDrop = async (files: File[]) => {
await addFiles(files);
@@ -36,6 +41,22 @@ const LandingPage = () => {
event.target.value = '';
};
// Determine if the user has any recent files (same source as File Manager)
useEffect(() => {
let isMounted = true;
(async () => {
try {
const files = await loadRecentFiles();
if (isMounted) {
setHasRecents((files?.length || 0) > 0);
}
} catch (_err) {
if (isMounted) setHasRecents(false);
}
})();
return () => { isMounted = false; };
}, [loadRecentFiles]);
return (
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
{/* White PDF Page Background */}
@@ -72,7 +93,7 @@ const LandingPage = () => {
}}
>
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
src={logoPath}
alt="Stirling PDF Logo"
style={{
height: 'auto',
@@ -117,59 +138,89 @@ const LandingPage = () => {
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
{/* Show both buttons only when recents exist; otherwise show a single Upload button */}
{hasRecents && (
<>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</>
)}
{!hasRecents && (
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: '100%',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</Button>
)}
</div>
{/* Hidden file input for native file picker */}
@@ -16,6 +16,7 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
import AppConfigModal from '@app/components/shared/AppConfigModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import {
isNavButtonActive,
getNavButtonStyle,
@@ -88,7 +89,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: () => handleClick(),
'aria-label': config.name
})}
size={isActive ? (config.size || 'lg') : 'lg'}
size={isActive ? 'lg' : 'md'}
variant="subtle"
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isActive ? 'activeIconScale' : ''}
@@ -108,9 +109,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const mainButtons: ButtonConfig[] = [
{
id: 'read',
name: t("quickAccess.read", "Read"),
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
size: 'lg',
name: t("quickAccess.reader", "Reader"),
icon: <LocalIcon icon="menu-book-rounded" width="1.25rem" height="1.25rem" />,
size: 'md',
isRound: false,
type: 'navigation',
onClick: () => {
@@ -118,23 +119,11 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
handleReaderToggle();
}
},
// {
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
// size: 'lg',
// isRound: false,
// type: 'navigation',
// onClick: () => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
// },
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
size: 'lg',
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
size: 'md',
isRound: false,
type: 'navigation',
onClick: () => {
@@ -147,37 +136,36 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}
}
},
];
const middleButtons: ButtonConfig[] = [
{
id: 'files',
name: t("quickAccess.files", "Files"),
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'lg',
size: 'md',
type: 'modal',
onClick: handleFilesButtonClick
},
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
];
const middleButtons: ButtonConfig[] = [];
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
const bottomButtons: ButtonConfig[] = [
{
id: 'help',
name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'lg',
size: 'md',
type: 'action',
onClick: () => {
// This will be overridden by the wrapper logic
@@ -185,9 +173,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
},
{
id: 'config',
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
name: t("quickAccess.settings", "Settings"),
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'md',
type: 'modal',
onClick: () => {
navigate('/settings/overview');
@@ -200,7 +188,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div
ref={ref}
data-sidebar="quick-access"
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
style={{
borderRight: '1px solid var(--border-default)'
}}
@@ -239,20 +227,30 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
))}
</Stack>
{/* Divider after main buttons */}
<Divider
size="xs"
className="content-divider"
/>
{/* Divider after main buttons (creates gap) */}
{middleButtons.length === 0 && (
<Divider
size="xs"
className="content-divider"
/>
)}
{/* Middle section */}
<Stack gap="lg" align="center">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
{middleButtons.length > 0 && (
<>
<Divider
size="xs"
className="content-divider"
/>
<Stack gap="lg" align="center">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
</>
)}
{/* Spacer to push bottom buttons to bottom */}
<div className="spacer" />
@@ -14,6 +14,8 @@ import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
@@ -39,7 +41,7 @@ export default function RightRail() {
const { sidebarRefs } = useSidebarContext();
const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext();
const { toggleTheme, themeMode } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
@@ -195,7 +197,11 @@ export default function RightRail() {
className="right-rail-icon"
onClick={toggleTheme}
>
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
{themeMode === 'dark' ? (
<LightModeIcon sx={{ fontSize: '1.5rem' }} />
) : (
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
)}
</ActionIcon>,
t('rightRail.toggleTheme', 'Toggle Theme')
)}
@@ -2,10 +2,10 @@ import React, { useState, useCallback, useMemo } from "react";
import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import rainbowStyles from '@app/styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import GridViewIcon from "@mui/icons-material/GridView";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { LocalIcon } from '@app/components/shared/LocalIcon';
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
@@ -55,7 +55,7 @@ const createViewOptions = (
{switchingTo === "viewer" ? (
<Loader size="sm" />
) : (
<VisibilityIcon fontSize="medium" />
<InsertDriveFileIcon fontSize="medium" />
)}
</div>
),
@@ -84,7 +84,7 @@ const createViewOptions = (
{switchingTo === "pageEditor" ? (
<Loader size="sm" />
) : (
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
<GridViewIcon fontSize="medium" />
)}
</div>
),
@@ -0,0 +1,38 @@
import { Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
interface LoginRequiredBannerProps {
show: boolean;
}
/**
* Banner component that displays when login mode is required but not enabled
* Shows prominent warning that settings are read-only
*/
export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<Alert
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
color="blue"
variant="light"
styles={{
root: {
borderLeft: '4px solid var(--mantine-color-blue-6)'
}
}}
>
<Text size="sm">
{t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')}
</Text>
<Text size="sm" fw={600} mt="xs" c="dimmed">
{t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')}
</Text>
</Alert>
);
}
@@ -2,18 +2,6 @@ import React from 'react';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
export interface ConfigNavItem {
key: NavKey;
@@ -40,8 +28,8 @@ export interface ConfigColors {
}
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
_isAdmin: boolean = false,
_runningEE: boolean = false,
_loginEnabled: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
@@ -64,112 +52,5 @@ export const createConfigNavSections = (
},
];
// Add Admin sections if user is admin
if (isAdmin) {
// Configuration
sections.push({
title: 'Configuration',
items: [
{
key: 'adminGeneral',
label: 'System Settings',
icon: 'settings-rounded',
component: <AdminGeneralSection />
},
{
key: 'adminFeatures',
label: 'Features',
icon: 'extension-rounded',
component: <AdminFeaturesSection />
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
component: <AdminDatabaseSection />
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />
},
],
});
// Security & Authentication
sections.push({
title: 'Security & Authentication',
items: [
{
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
component: <AdminSecuritySection />
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
component: <AdminConnectionsSection />
},
],
});
// Licensing & Analytics
sections.push({
title: 'Licensing & Analytics',
items: [
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />
},
{
key: 'adminAudit',
label: 'Audit',
icon: 'fact-check-rounded',
component: <AdminAuditSection />,
disabled: !runningEE,
disabledTooltip: 'Requires Enterprise license'
},
{
key: 'adminUsage',
label: 'Usage Analytics',
icon: 'analytics-rounded',
component: <AdminUsageSection />,
disabled: !runningEE,
disabledTooltip: 'Requires Enterprise license'
},
],
});
// Policies & Privacy
sections.push({
title: 'Policies & Privacy',
items: [
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
component: <AdminLegalSection />
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
component: <AdminPrivacySection />
},
],
});
}
return sections;
};
@@ -114,6 +114,23 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
]}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.rememberInputs', 'Remember tool inputs')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
'settings.general.rememberInputsDescription',
'Store the last settings you used for each tool on this device'
)}
</Text>
</div>
<Switch
checked={preferences.rememberFormInputs}
onChange={(event) => updatePreference('rememberFormInputs', event.currentTarget.checked)}
/>
</div>
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
@@ -10,6 +10,7 @@ interface ProviderCardProps {
settings?: Record<string, any>;
onSave?: (settings: Record<string, any>) => void;
onDisconnect?: () => void;
disabled?: boolean;
}
export default function ProviderCard({
@@ -18,6 +19,7 @@ export default function ProviderCard({
settings = {},
onSave,
onDisconnect,
disabled = false,
}: ProviderCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@@ -39,6 +41,7 @@ export default function ProviderCard({
};
const handleFieldChange = (key: string, value: any) => {
if (disabled) return; // Block changes when disabled
setLocalSettings((prev) => ({ ...prev, [key]: value }));
};
@@ -63,6 +66,7 @@ export default function ProviderCard({
<Switch
checked={value || false}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
disabled={disabled}
/>
</div>
);
@@ -76,6 +80,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
@@ -88,6 +93,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
@@ -100,6 +106,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
}
@@ -174,11 +181,12 @@ export default function ProviderCard({
color="red"
size="sm"
onClick={onDisconnect}
disabled={disabled}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
</Button>
)}
<Button size="sm" onClick={handleSave}>
<Button size="sm" onClick={handleSave} disabled={disabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -1,13 +1,13 @@
/**
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
*
* When a user selects a tool from the All Tools list, this component displays the tool's
* When a user selects a tool from the Tools list, this component displays the tool's
* icon and name at the top of the navigation bar. It provides a quick way to see which
* tool is currently active and offers a back button to return to the All Tools list.
* tool is currently active and offers a back button to return to the Tools list.
*
* Features:
* - Shows tool icon and name when a tool is selected
* - Hover to reveal back arrow for returning to All Tools
* - Hover to reveal back arrow for returning to Tools
* - Smooth slide-down/slide-up animations
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
*/
@@ -149,7 +149,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
handleBackToTools();
});
}}
size={'xl'}
size={'lg'}
variant="subtle"
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
@@ -165,7 +165,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
>
<span className="iconContainer">
{isBackHover ? (
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
<ArrowBackRoundedIcon sx={{ fontSize: '1.875rem' }} />
) : (
indicatorTool.icon
)}
@@ -1,15 +1,29 @@
.activeIconScale {
transform: scale(1.3);
transform: scale(1);
transition: transform 0.2s;
z-index: 1;
width: calc(1.75rem + 10px) !important;
height: calc(1.75rem + 10px) !important;
}
.activeIconScale .iconContainer {
width: calc(1.5rem + 10px);
height: calc(1.5rem + 10px);
}
.activeIconScale .iconContainer svg,
.activeIconScale .iconContainer img {
width: calc(1.25rem + 10px) !important;
height: calc(1.25rem + 10px) !important;
}
.iconContainer {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
width: 1.5rem;
height: 1.5rem;
transition: width 0.2s, height 0.2s;
}
/* Action icon styles */
@@ -24,9 +38,9 @@
/* Main container styles */
.quick-access-bar-main {
background-color: var(--bg-muted);
width: 5rem;
min-width: 5rem;
max-width: 5rem;
width: 4rem;
min-width: 4rem;
max-width: 4rem;
position: relative;
z-index: 10;
}
@@ -34,9 +48,9 @@
/* Rainbow mode container */
.quick-access-bar-main.rainbow-mode {
background-color: var(--bg-muted);
width: 5rem;
min-width: 5rem;
max-width: 5rem;
width: 4rem;
min-width: 4rem;
max-width: 4rem;
position: relative;
z-index: 10;
}
@@ -57,7 +71,7 @@
/* Nav header divider */
.nav-header-divider {
width: 3.75rem;
width: 3rem;
border-color: var(--color-gray-300);
margin-top: 0.5rem;
margin-bottom: 1rem;
@@ -85,7 +99,7 @@
/* Overflow divider */
.overflow-divider {
width: 3.75rem;
width: 3rem;
border-color: var(--color-gray-300);
margin: 0 0.5rem;
}
@@ -143,7 +157,7 @@
/* Content divider */
.content-divider {
width: 3.75rem;
width: 3rem;
border-color: var(--color-gray-300);
margin: 1rem 0;
}
@@ -241,7 +255,7 @@
/* Divider that animates growing from top */
.current-tool-divider {
width: 3.75rem;
width: 3rem;
border-color: var(--color-gray-300);
margin: 0.5rem auto 0.5rem auto;
transform-origin: top;
@@ -8,6 +8,7 @@ import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId';
import { useFocusTrap } from '@app/hooks/useFocusTrap';
import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
import { Tooltip } from '@app/components/shared/Tooltip';
import '@app/components/tools/ToolPanel.css';
import { ToolPanelGeometry } from '@app/hooks/tools/useToolPanelGeometry';
@@ -51,9 +52,7 @@ const FullscreenToolSurface = ({
useFocusTrap(surfaceRef, !isExiting);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandIconSrc = useLogoPath();
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;
+59 -162
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
import React, { useMemo, useRef } from "react";
import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
@@ -8,11 +8,10 @@ import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import NoToolsFound from "@app/components/tools/shared/NoToolsFound";
import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons";
import Badge from "@app/components/shared/Badge";
import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader";
import ToolButton from "@app/components/tools/toolPicker/ToolButton";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
interface ToolPickerProps {
selectedToolKey: string | null;
@@ -23,49 +22,8 @@ interface ToolPickerProps {
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
const { t } = useTranslation();
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
const scrollableRef = useRef<HTMLDivElement>(null);
const quickHeaderRef = useRef<HTMLDivElement>(null);
const allHeaderRef = useRef<HTMLDivElement>(null);
const quickAccessRef = useRef<HTMLDivElement>(null);
const allToolsRef = useRef<HTMLDivElement>(null);
// Keep header heights in sync with any dynamic size changes
useLayoutEffect(() => {
const update = () => {
if (quickHeaderRef.current) {
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
}
if (allHeaderRef.current) {
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
}
};
update();
// Update on window resize
window.addEventListener("resize", update);
// Update on element resize (e.g., font load, badge count change, zoom)
const observers: ResizeObserver[] = [];
if (typeof ResizeObserver !== "undefined") {
const observe = (el: HTMLDivElement | null, cb: () => void) => {
if (!el) return;
const ro = new ResizeObserver(() => cb());
ro.observe(el);
observers.push(ro);
};
observe(quickHeaderRef.current, update);
observe(allHeaderRef.current, update);
}
return () => {
window.removeEventListener("resize", update);
observers.forEach(o => o.disconnect());
};
}, []);
const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow();
@@ -84,31 +42,25 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
return items;
}, [quickSection]);
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
const allSection = useMemo(
() => visibleSections.find(s => s.key === 'all'),
[visibleSections]
);
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
const container = scrollableRef.current;
const target = ref.current;
if (container && target) {
const stackedOffset = ref === allToolsRef
? (quickHeaderHeight + allHeaderHeight)
: quickHeaderHeight;
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
container.scrollTo({
top: Math.max(0, top),
behavior: "smooth"
});
}
};
// Build flat list by subcategory for search mode
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
const headerTextStyle: React.CSSProperties = {
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.5rem 0 0.25rem 0.5rem",
textTransform: "none",
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
opacity: 0.7
};
const toTitleCase = (s: string) =>
s.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase());
return (
<Box
@@ -141,110 +93,55 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
</Stack>
) : (
<>
{quickSection && (
<>
<div
ref={quickHeaderRef}
style={{
position: "sticky",
top: 0,
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 600,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
onClick={() => scrollTo(quickAccessRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
<Badge>
{recommendedCount}
</Badge>
</div>
<Box ref={quickAccessRef} w="100%" my="sm">
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.favorites', 'Favourites')} mt={0} />
<div>
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.recommended', 'Recommended')} />
<div>
{recommendedItems.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
</Stack>
{/* Flat list: favorites and recommended first, then all subcategories */}
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>
{t('toolPanel.fullscreen.favorites', 'Favourites')}
</div>
<div>
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
</>
)}
{allSection && (
<>
<div
ref={allHeaderRef}
style={{
position: "sticky",
top: quickSection ? quickHeaderHeight -1 : 0,
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 600,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
onClick={() => scrollTo(allToolsRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
<Badge>
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</Badge>
</div>
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
)}
</Stack>
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>
{t('toolPanel.fullscreen.recommended', 'Recommended')}
</div>
<div>
{recommendedItems.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
</>
)}
)}
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
<Box key={sc.subcategoryId} w="100%">
<div style={headerTextStyle}>
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
</div>
{renderToolButtons(t, sc, selectedToolKey, onSelect, false, false, undefined, true)}
</Box>
))}
</Stack>
{!quickSection && !allSection && <NoToolsFound />}
@@ -80,7 +80,7 @@ export default function ToolSelector({
// If no sections, create a simple group from filtered tools
if (baseFilteredTools.length > 0) {
return [{
name: 'All Tools',
name: 'Tools',
subcategoryId: 'all' as any,
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
}];
@@ -19,6 +19,7 @@ export interface AppConfig {
serverPort?: number;
appNameNavbar?: string;
languages?: string[];
logoStyle?: 'modern' | 'classic';
enableLogin?: boolean;
enableEmailInvites?: boolean;
isAdmin?: boolean;
@@ -0,0 +1,26 @@
import { createContext, useContext, useState, ReactNode } from 'react';
interface BannerContextType {
banner: ReactNode;
setBanner: (banner: ReactNode) => void;
}
const BannerContext = createContext<BannerContextType | undefined>(undefined);
export function BannerProvider({ children }: { children: ReactNode }) {
const [banner, setBanner] = useState<ReactNode>(null);
return (
<BannerContext.Provider value={{ banner, setBanner }}>
{children}
</BannerContext.Provider>
);
}
export function useBanner() {
const context = useContext(BannerContext);
if (!context) {
throw new Error('useBanner must be used within BannerProvider');
}
return context;
}
@@ -68,7 +68,7 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
const restoreWorkbenchState = useCallback(async () => {
console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length);
// Go back to All Tools
// Go back to Tools
handleBackToTools();
// Clear all files (including tour sample)
@@ -2,10 +2,13 @@ import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAddPasswordParameters, defaultParameters, AddPasswordParametersHook } from '@app/hooks/tools/addPassword/useAddPasswordParameters';
import { defaultParameters as defaultChangePermissionsParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderAddPasswordHook = () => renderHook(() => useAddPasswordParameters(), { wrapper: PreferencesTestWrapper });
describe('useAddPasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -15,7 +18,7 @@ describe('useAddPasswordParameters', () => {
{ paramName: 'ownerPassword' as const, value: 'owner-password' },
{ paramName: 'keyLength' as const, value: 256 }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -28,7 +31,7 @@ describe('useAddPasswordParameters', () => {
{ paramName: 'preventAssembly' as const },
{ paramName: 'preventPrinting' as const }
])('should update boolean permission parameter $paramName', ({ paramName }) => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
act(() => {
result.current.permissions.updateParameter(paramName, true);
@@ -38,7 +41,7 @@ describe('useAddPasswordParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
// First, change some parameters
act(() => {
@@ -60,7 +63,7 @@ describe('useAddPasswordParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
expect(result.current.getEndpointName()).toBe('add-password');
});
@@ -112,7 +115,7 @@ describe('useAddPasswordParameters', () => {
}
}
])('should validate parameters correctly $description', ({ setup }) => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -126,7 +129,7 @@ describe('useAddPasswordParameters', () => {
});
test.each(Object.keys(defaultChangePermissionsParameters) as Array<keyof ChangePermissionsParameters>)('should handle boolean restriction parameter %s', (param) => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
act(() => {
result.current.resetParameters();
@@ -137,7 +140,7 @@ describe('useAddPasswordParameters', () => {
});
test('should handle mixed parameter types in updateParameter', () => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
act(() => {
result.current.updateParameter('password', 'test-string');
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAdjustPageScaleParameters, defaultParameters, PageSize, AdjustPageScaleParametersHook } from '@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderAdjustPageScaleHook = () => renderHook(() => useAdjustPageScaleParameters(), { wrapper: PreferencesTestWrapper });
describe('useAdjustPageScaleParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
expect(result.current.parameters.scaleFactor).toBe(1.0);
@@ -19,7 +22,7 @@ describe('useAdjustPageScaleParameters', () => {
{ paramName: 'pageSize' as const, value: PageSize.LETTER },
{ paramName: 'pageSize' as const, value: PageSize.LEGAL },
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -29,7 +32,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
// First, change some parameters
act(() => {
@@ -49,7 +52,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
expect(result.current.getEndpointName()).toBe('scale-pages');
});
@@ -96,7 +99,7 @@ describe('useAdjustPageScaleParameters', () => {
expected: false
}
])('should validate parameters correctly $description', ({ setup, expected }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
act(() => {
setup(result.current);
@@ -106,7 +109,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should handle all PageSize enum values', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
Object.values(PageSize).forEach(pageSize => {
act(() => {
@@ -119,7 +122,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should handle scale factor edge cases', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
// Test very small valid scale factor
act(() => {
@@ -2,10 +2,13 @@ import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useChangeMetadataParameters } from '@app/hooks/tools/changeMetadata/useChangeMetadataParameters';
import { TrappedStatus } from '@app/types/metadata';
import { describe, expect, test } from 'vitest';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderChangeMetadataHook = () => renderHook(() => useChangeMetadataParameters(), { wrapper: PreferencesTestWrapper });
describe('useChangeMetadataParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -23,7 +26,7 @@ describe('useChangeMetadataParameters', () => {
{ paramName: 'trapped', value: TrappedStatus.TRUE },
{ paramName: 'deleteAll', value: true },
] as const)('should update $paramName parameter', ({ paramName, value }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -48,7 +51,7 @@ describe('useChangeMetadataParameters', () => {
{ description: 'no meaningful content', updates: {}, expected: false },
{ description: 'whitespace only', updates: { title: ' ', author: ' ' }, expected: false },
])('should validate correctly when $description', ({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
Object.entries(updates).forEach(([key, value]) => {
@@ -64,7 +67,7 @@ describe('useChangeMetadataParameters', () => {
{ description: 'valid modification date', updates: { title: 'Test', modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'empty dates are valid', updates: { title: 'Test', creationDate: null, modificationDate: null }, expected: true },
])('should validate dates correctly with $description', ({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
Object.entries(updates).forEach(([key, value]) => {
@@ -78,7 +81,7 @@ describe('useChangeMetadataParameters', () => {
describe('custom metadata', () => {
test('should add custom metadata with sequential IDs', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.addCustomMetadata();
@@ -93,7 +96,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should remove custom metadata by ID', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.addCustomMetadata();
@@ -109,7 +112,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should update custom metadata by ID', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.addCustomMetadata();
@@ -129,7 +132,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should validate with custom metadata', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.addCustomMetadata();
@@ -145,7 +148,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should generate unique IDs for multiple custom entries', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
for (let i = 0; i < 3; i++) {
act(() => {
@@ -161,7 +164,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
expect(result.current.getEndpointName()).toBe('update-metadata');
});
@@ -1,16 +1,19 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useChangePermissionsParameters, defaultParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderChangePermissionsHook = () => renderHook(() => useChangePermissionsParameters(), { wrapper: PreferencesTestWrapper });
describe('useChangePermissionsParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual boolean parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
act(() => {
result.current.updateParameter('preventAssembly', true);
@@ -28,7 +31,7 @@ describe('useChangePermissionsParameters', () => {
});
test('should update all permission parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
@@ -56,7 +59,7 @@ describe('useChangePermissionsParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
// First, change some parameters
act(() => {
@@ -78,13 +81,13 @@ describe('useChangePermissionsParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
expect(result.current.getEndpointName()).toBe('add-password');
});
test('should always validate as true', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -6,13 +6,16 @@ import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { FIT_OPTIONS } from '@app/constants/convertConstants';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderConvertHook = () => renderHook(() => useConvertParameters(), { wrapper: PreferencesTestWrapper });
describe('useConvertParameters', () => {
describe('Parameter Management', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
expect(result.current.parameters.fromExtension).toBe('');
expect(result.current.parameters.toExtension).toBe('');
@@ -28,7 +31,7 @@ describe('useConvertParameters', () => {
});
test('should update individual parameters', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -39,7 +42,7 @@ describe('useConvertParameters', () => {
});
test('should update nested image options', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('imageOptions', {
@@ -61,7 +64,7 @@ describe('useConvertParameters', () => {
});
test('should update nested HTML options', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('htmlOptions', {
@@ -73,7 +76,7 @@ describe('useConvertParameters', () => {
});
test('should update nested email options', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('emailOptions', {
@@ -91,7 +94,7 @@ describe('useConvertParameters', () => {
});
test('should update nested PDF/A options', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('pdfaOptions', {
@@ -103,7 +106,7 @@ describe('useConvertParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -124,7 +127,7 @@ describe('useConvertParameters', () => {
describe('Parameter Validation', () => {
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
// No parameters - should be invalid
expect(result.current.validateParameters()).toBe(false);
@@ -143,7 +146,7 @@ describe('useConvertParameters', () => {
});
test('should validate unsupported conversions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -158,7 +161,7 @@ describe('useConvertParameters', () => {
describe('Endpoint Generation', () => {
test('should generate correct endpoint names', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -170,7 +173,7 @@ describe('useConvertParameters', () => {
});
test('should generate correct endpoint URLs', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -182,7 +185,7 @@ describe('useConvertParameters', () => {
});
test('should return empty strings for invalid conversions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'invalid');
@@ -197,7 +200,7 @@ describe('useConvertParameters', () => {
describe('Available Extensions', () => {
test('should return available extensions for valid source format', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const availableExtensions = result.current.getAvailableToExtensions('pdf');
@@ -207,7 +210,7 @@ describe('useConvertParameters', () => {
});
test('should return empty array for invalid source format', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const availableExtensions = result.current.getAvailableToExtensions('invalid');
@@ -219,7 +222,7 @@ describe('useConvertParameters', () => {
});
test('should return empty array for empty source format', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const availableExtensions = result.current.getAvailableToExtensions('');
@@ -137,6 +137,7 @@ export const useConvertParameters = (): ConvertParametersHook => {
defaultParameters,
endpointName: getEndpointName,
validateFn: validateParameters,
storageKey: 'convert',
}), []);
const baseHook = useBaseParameters(config);
@@ -6,13 +6,16 @@
import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderConvertHook = () => renderHook(() => useConvertParameters(), { wrapper: PreferencesTestWrapper });
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Single File Detection', () => {
test('should detect single file extension and set auto-target', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const pdfFile = [{ name: 'document.pdf' }];
@@ -27,7 +30,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle unknown file types with file-to-pdf fallback', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const unknownFile = [{ name: 'document.xyz' }, { name: 'image.jpggg' }];
@@ -41,7 +44,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle files without extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const noExtFile = [{ name: 'document' }];
@@ -59,7 +62,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Multiple Identical Files', () => {
test('should detect multiple PDF files and set auto-target', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const pdfFiles = [
{ name: 'doc1.pdf' },
@@ -78,7 +81,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle multiple unknown file types with fallback', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const unknownFiles = [
{ name: 'file1.xyz' },
@@ -98,7 +101,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection - All Images', () => {
test('should detect all image files and enable smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const imageFiles = [
{ name: 'photo1.jpg' },
@@ -117,7 +120,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle mixed case image extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const imageFiles = [
{ name: 'photo1.JPG' },
@@ -136,7 +139,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection - All Web Files', () => {
test('should detect all web files and enable web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const webFiles = [
{ name: 'page1.html' },
@@ -154,7 +157,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle mixed case web extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const webFiles = [
{ name: 'page1.HTML' },
@@ -170,7 +173,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should detect multiple web files and enable web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const zipFiles = [
{ name: 'site1.zip' },
@@ -191,7 +194,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection - Mixed File Types', () => {
test('should detect mixed file types and enable smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const mixedFiles = [
{ name: 'document.pdf' },
@@ -210,7 +213,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should detect mixed images and documents as mixed type', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const mixedFiles = [
{ name: 'photo.jpg' },
@@ -227,7 +230,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle mixed with unknown file types', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const mixedFiles = [
{ name: 'document.pdf' },
@@ -247,7 +250,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection Endpoint Resolution', () => {
test('should return correct endpoint for image smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const imageFiles = [
{ name: 'photo1.jpg' },
@@ -263,7 +266,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should return correct endpoint for web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const webFiles = [
{ name: 'page1.html' },
@@ -279,7 +282,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should return correct endpoint for mixed smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const mixedFiles = [
{ name: 'document.pdf' },
@@ -298,7 +301,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Auto-Target Selection Logic', () => {
test('should select single available target automatically', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
// Markdown has only one conversion target (PDF)
const mdFile = [{ name: 'readme.md' }];
@@ -312,7 +315,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should not auto-select when multiple targets available', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
// PDF has multiple conversion targets, so no auto-selection
const pdfFile = [{ name: 'document.pdf' }];
@@ -330,7 +333,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Edge Cases', () => {
test('should handle empty file names', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const emptyFiles = [{ name: '' }];
@@ -343,7 +346,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle malformed file objects', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const malformedFiles: Array<{name: string}> = [
{ name: 'valid.pdf' },
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMergeParameters, defaultParameters } from '@app/hooks/tools/merge/useMergeParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderMergeHook = () => renderHook(() => useMergeParameters(), { wrapper: PreferencesTestWrapper });
describe('useMergeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useMergeParameters());
const { result } = renderMergeHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -15,7 +18,7 @@ describe('useMergeParameters', () => {
{ paramName: 'generateTableOfContents' as const, value: true },
{ paramName: 'generateTableOfContents' as const, value: false }
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useMergeParameters());
const { result } = renderMergeHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -25,7 +28,7 @@ describe('useMergeParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useMergeParameters());
const { result } = renderMergeHook();
// First, change some parameters
act(() => {
@@ -45,7 +48,7 @@ describe('useMergeParameters', () => {
});
test('should validate parameters correctly - always returns true', () => {
const { result } = renderHook(() => useMergeParameters());
const { result } = renderMergeHook();
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRedactParameters, defaultParameters } from '@app/hooks/tools/redact/useRedactParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderRedactHook = () => renderHook(() => useRedactParameters(), { wrapper: PreferencesTestWrapper });
describe('useRedactParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -18,7 +21,7 @@ describe('useRedactParameters', () => {
{ paramName: 'customPadding' as const, value: 0.5 },
{ paramName: 'convertPDFToImage' as const, value: false }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -28,7 +31,7 @@ describe('useRedactParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
// Modify some parameters
act(() => {
@@ -52,7 +55,7 @@ describe('useRedactParameters', () => {
{ description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
{ description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
])('should $description', ({ wordsToRedact, expected }) => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('mode', 'automatic');
@@ -63,7 +66,7 @@ describe('useRedactParameters', () => {
});
test('should not validate in manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('mode', 'manual');
@@ -75,7 +78,7 @@ describe('useRedactParameters', () => {
describe('endpoint handling', () => {
test('should return correct endpoint for automatic mode', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('mode', 'automatic');
@@ -85,7 +88,7 @@ describe('useRedactParameters', () => {
});
test('should throw error for manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('mode', 'manual');
@@ -96,7 +99,7 @@ describe('useRedactParameters', () => {
});
test('should maintain parameter state across updates', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('redactColor', '#FF0000');
@@ -117,7 +120,7 @@ describe('useRedactParameters', () => {
});
test('should handle array parameter updates correctly', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('wordsToRedact', ['initial']);
@@ -43,6 +43,7 @@ export const useRedactParameters = (): RedactParametersHook => {
}
// Manual mode validation would go here when implemented
return false;
}
},
storageKey: 'redact'
});
};
@@ -1,16 +1,19 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderRemovePasswordHook = () => renderHook(() => useRemovePasswordParameters(), { wrapper: PreferencesTestWrapper });
describe('useRemovePasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update password parameter', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
act(() => {
result.current.updateParameter('password', 'test-password');
@@ -20,7 +23,7 @@ describe('useRemovePasswordParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
// First, change the password
act(() => {
@@ -38,7 +41,7 @@ describe('useRemovePasswordParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
expect(result.current.getEndpointName()).toBe('remove-password');
});
@@ -70,7 +73,7 @@ describe('useRemovePasswordParameters', () => {
expectedValid: true
}
])('should validate parameters correctly $description', ({ password, expectedValid }) => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
act(() => {
result.current.updateParameter('password', password);
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRotateParameters, defaultParameters, normalizeAngle } from '@app/hooks/tools/rotate/useRotateParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderRotateHook = () => renderHook(() => useRotateParameters(), { wrapper: PreferencesTestWrapper });
describe('useRotateParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.parameters.angle).toBe(0);
@@ -12,7 +15,7 @@ describe('useRotateParameters', () => {
});
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
// Default should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -31,7 +34,7 @@ describe('useRotateParameters', () => {
});
test('should rotate clockwise correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
act(() => {
result.current.rotateClockwise();
@@ -58,7 +61,7 @@ describe('useRotateParameters', () => {
});
test('should rotate anticlockwise correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
act(() => {
result.current.rotateAnticlockwise();
@@ -85,7 +88,7 @@ describe('useRotateParameters', () => {
});
test('should normalize angles correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
expect(result.current.normalizeAngle(360)).toBe(0);
expect(result.current.normalizeAngle(450)).toBe(90);
@@ -94,7 +97,7 @@ describe('useRotateParameters', () => {
});
test('should reset parameters correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
// Set some rotation
act(() => {
@@ -116,7 +119,7 @@ describe('useRotateParameters', () => {
});
test('should update parameters', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
act(() => {
result.current.updateParameter('angle', 450);
@@ -132,13 +135,13 @@ describe('useRotateParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
expect(result.current.getEndpointName()).toBe('rotate-pdf');
});
test('should detect rotation state correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
// Initially no rotation
expect(result.current.hasRotation).toBe(false);
@@ -1,16 +1,19 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useSanitizeParameters } from '@app/hooks/tools/sanitize/useSanitizeParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderSanitizeHook = () => renderHook(() => useSanitizeParameters(), { wrapper: PreferencesTestWrapper });
describe('useSanitizeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual parameters', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
act(() => {
result.current.updateParameter('removeXMPMetadata', true);
@@ -23,7 +26,7 @@ describe('useSanitizeParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
// First, change some parameters
act(() => {
@@ -43,13 +46,13 @@ describe('useSanitizeParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
expect(result.current.getEndpointName()).toBe('sanitize-pdf');
});
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
// Default state should be valid (has removeJavaScript and removeEmbeddedFiles enabled)
expect(result.current.validateParameters()).toBe(true);
@@ -71,7 +74,7 @@ describe('useSanitizeParameters', () => {
});
test('should handle all parameter types correctly', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
const allParameters = Object.keys(defaultParameters) as (keyof typeof defaultParameters)[];
@@ -1,4 +1,6 @@
import { useState, useCallback, Dispatch, SetStateAction } from 'react';
import { useState, useCallback, Dispatch, SetStateAction, useRef, useEffect } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { loadToolParameters, saveToolParameters } from '@app/services/toolParameterStorage';
export interface BaseParametersHook<T> {
parameters: T;
@@ -13,10 +15,31 @@ export interface BaseParametersConfig<T> {
defaultParameters: T;
endpointName: string | ((params: T) => string);
validateFn?: (params: T) => boolean;
storageKey?: string;
}
export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParametersHook<T> {
const [parameters, setParameters] = useState<T>(config.defaultParameters);
const { preferences } = usePreferences();
const storageKey = config.storageKey ?? (typeof config.endpointName === 'string' ? config.endpointName : undefined);
const shouldPersist = Boolean(storageKey && preferences.rememberFormInputs);
const hasHydratedFromStorage = useRef(false);
const mergeWithDefaults = useCallback((values?: Partial<T>): T => ({
...(config.defaultParameters as T),
...(values ?? {}),
}), [config.defaultParameters]);
const [parameters, setParameters] = useState<T>(() => {
if (shouldPersist && storageKey) {
const stored = loadToolParameters<T>(storageKey);
if (stored) {
hasHydratedFromStorage.current = true;
return mergeWithDefaults(stored);
}
}
return mergeWithDefaults();
});
const updateParameter = useCallback(<K extends keyof T>(parameter: K, value: T[K]) => {
setParameters(prev => ({
@@ -33,6 +56,34 @@ export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParam
return config.validateFn ? config.validateFn(parameters) : true;
}, [parameters, config.validateFn]);
useEffect(() => {
if (!storageKey) {
return;
}
if (!preferences.rememberFormInputs) {
hasHydratedFromStorage.current = false;
return;
}
if (hasHydratedFromStorage.current) {
return;
}
const stored = loadToolParameters<T>(storageKey);
if (stored) {
setParameters(mergeWithDefaults(stored));
}
hasHydratedFromStorage.current = true;
}, [mergeWithDefaults, preferences.rememberFormInputs, storageKey]);
useEffect(() => {
if (!storageKey || !preferences.rememberFormInputs) {
return;
}
saveToolParameters(storageKey, parameters);
}, [parameters, preferences.rememberFormInputs, storageKey]);
const endpointName = config.endpointName;
let getEndpointName: () => string;
if (typeof endpointName === "string") {
@@ -57,5 +57,6 @@ export const useSplitParameters = (): SplitParametersHook => {
return false;
}
},
storageKey: 'split',
});
};
@@ -0,0 +1,89 @@
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { alert } from '@app/components/toast';
import { useTranslation } from 'react-i18next';
/**
* Hook to manage login-required functionality in admin sections
* Provides login state, validation, and alert functionality
*/
export function useLoginRequired() {
const { config } = useAppConfig();
const { t } = useTranslation();
const loginEnabled = config?.enableLogin ?? true;
/**
* Show alert when user tries to modify settings with login disabled
*/
const showLoginRequiredAlert = () => {
alert({
alertType: 'warning',
title: t('admin.error', 'Error'),
body: t('admin.settings.loginRequired', 'Login mode must be enabled to modify admin settings'),
});
};
/**
* Validate that login is enabled before allowing action
* Returns true if login is enabled, false otherwise (and shows alert)
*/
const validateLoginEnabled = (): boolean => {
if (!loginEnabled) {
showLoginRequiredAlert();
return false;
}
return true;
};
/**
* Wrap an async handler to check login state before executing
*/
const withLoginCheck = <T extends (...args: any[]) => Promise<any>>(
handler: T
): T => {
return (async (...args: any[]) => {
if (!validateLoginEnabled()) {
return;
}
return handler(...args);
}) as T;
};
/**
* Get styles for disabled inputs (cursor not-allowed)
*/
const getDisabledStyles = () => {
if (!loginEnabled) {
return {
input: { cursor: 'not-allowed' },
track: { cursor: 'not-allowed' },
thumb: { cursor: 'not-allowed' }
};
}
return undefined;
};
/**
* Wrap fetch function to skip API call when login disabled
*/
const withLoginCheckForFetch = <T extends (...args: any[]) => Promise<any>>(
fetchHandler: T,
skipWhenDisabled: boolean = true
): T => {
return (async (...args: any[]) => {
if (!loginEnabled && skipWhenDisabled) {
// Skip fetch when login disabled - component will use default/empty values
return;
}
return fetchHandler(...args);
}) as T;
};
return {
loginEnabled,
showLoginRequiredAlert,
validateLoginEnabled,
withLoginCheck,
withLoginCheckForFetch,
getDisabledStyles,
};
}
+31
View File
@@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useMantineColorScheme } from '@mantine/core';
import { BASE_PATH } from '@app/constants/app';
/**
* Hook to get the correct logo path based on app config (logo style) and theme (light/dark)
*
* Logo styles:
* - classic: branding/old/favicon.svg (classic S logo - default)
* - modern: StirlingPDFLogoNoText{Light|Dark}.svg (minimalist modern design)
*
* @returns The path to the appropriate logo SVG file
*/
export function useLogoPath(): string {
const { config } = useAppConfig();
const { colorScheme } = useMantineColorScheme();
return useMemo(() => {
const logoStyle = config?.logoStyle || 'classic';
if (logoStyle === 'classic') {
// Classic logo (old favicon) - same for both light and dark modes
return `${BASE_PATH}/branding/old/favicon.svg`;
}
// Modern logo - different for light and dark modes
const themeSuffix = colorScheme === 'dark' ? 'Dark' : 'Light';
return `${BASE_PATH}/branding/StirlingPDFLogoNoText${themeSuffix}.svg`;
}, [config?.logoStyle, colorScheme]);
}
+6 -2
View File
@@ -64,8 +64,12 @@ export function useToolSections(
Object.entries(subs).forEach(([s, tools]) => {
const subcategoryId = s as SubcategoryId;
if (!all[subcategoryId]) all[subcategoryId] = [];
all[subcategoryId].push(...tools);
// Build the 'all' collection without duplicating recommended tools
// Recommended tools are shown in the Quick section only
if (categoryId !== ToolCategoryId.RECOMMENDED_TOOLS) {
if (!all[subcategoryId]) all[subcategoryId] = [];
all[subcategoryId].push(...tools);
}
});
if (categoryId === ToolCategoryId.RECOMMENDED_TOOLS) {
+4 -5
View File
@@ -8,6 +8,7 @@ import { BASE_PATH } from "@app/constants/app";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useLogoPath } from "@app/hooks/useLogoPath";
import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "@app/components/tools/ToolPanel";
@@ -60,9 +61,7 @@ export default function HomePage() {
}, [config]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandIconSrc = useLogoPath();
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;
@@ -219,7 +218,7 @@ export default function HomePage() {
<div className="mobile-bottom-bar">
<button
className="mobile-bottom-button"
aria-label={t('quickAccess.allTools', 'All Tools')}
aria-label={t('quickAccess.allTools', 'Tools')}
onClick={() => {
handleBackToTools();
if (isMobile) {
@@ -228,7 +227,7 @@ export default function HomePage() {
}}
>
<AppsIcon sx={{ fontSize: '1.5rem' }} />
<span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'All Tools')}</span>
<span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'Tools')}</span>
</button>
<button
className="mobile-bottom-button"
@@ -31,4 +31,14 @@ export const accountService = {
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password', formData);
},
/**
* Change user password on first login (resets firstLogin flag)
*/
async changePasswordOnLogin(currentPassword: string, newPassword: string): Promise<void> {
const formData = new FormData();
formData.append('currentPassword', currentPassword);
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password-on-login', formData);
},
};
+1
View File
@@ -7,6 +7,7 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig';
const apiClient = axios.create({
baseURL: getApiBaseUrl(),
responseType: 'json',
withCredentials: true,
});
// Setup interceptors (core does nothing, proprietary adds JWT auth)
+26 -1
View File
@@ -92,6 +92,32 @@ export async function handleHttpError(error: any): Promise<boolean> {
if (error?.config?.suppressErrorToast === true) {
return false; // Don't show global toast, but continue rejection
}
// Handle 401 authentication errors
const status: number | undefined = error?.response?.status;
if (status === 401) {
const pathname = window.location.pathname;
// Check if we're already on an auth page
const isAuthPage = pathname.includes('/login') ||
pathname.includes('/signup') ||
pathname.includes('/auth/') ||
pathname.includes('/invite/');
// If not on auth page, redirect to login with expired session message
if (!isAuthPage) {
console.debug('[httpErrorHandler] 401 detected, redirecting to login');
// Store the current location so we can redirect back after login
const currentLocation = window.location.pathname + window.location.search;
// Redirect to login with state
window.location.href = `/login?expired=true&from=${encodeURIComponent(currentLocation)}`;
return true; // Suppress toast since we're redirecting
}
// On auth pages, suppress the toast (user is already trying to authenticate)
console.debug('[httpErrorHandler] Suppressing 401 on auth page:', pathname);
return true;
}
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
@@ -112,7 +138,6 @@ export async function handleHttpError(error: any): Promise<boolean> {
// 2) Generic-vs-special dedupe by endpoint
const url: string | undefined = error?.config?.url;
const status: number | undefined = error?.response?.status;
const now = Date.now();
const isSpecial =
status === 422 ||
@@ -9,6 +9,7 @@ export interface UserPreferences {
toolPanelModePromptSeen: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
rememberFormInputs: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -19,6 +20,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
toolPanelModePromptSeen: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
rememberFormInputs: false,
};
const STORAGE_KEY = 'stirlingpdf_preferences';
@@ -0,0 +1,114 @@
const STORAGE_KEY = 'stirlingpdf_tool_parameters';
type SerializableValue = string | number | boolean | null | SerializableValue[] | { [key: string]: SerializableValue };
const isFile = (value: unknown): value is File => typeof File !== 'undefined' && value instanceof File;
const isBlob = (value: unknown): value is Blob => typeof Blob !== 'undefined' && value instanceof Blob;
function sanitizeValue(value: unknown): SerializableValue | undefined {
if (value === null) {
return null;
}
const valueType = typeof value;
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
if (valueType === 'number' && Number.isNaN(value)) {
return null;
}
return value as SerializableValue;
}
if (valueType === 'undefined' || valueType === 'function' || valueType === 'symbol' || valueType === 'bigint') {
return undefined;
}
if (Array.isArray(value)) {
const sanitizedArray = value
.map((item) => sanitizeValue(item))
.filter((item) => item !== undefined) as SerializableValue[];
return sanitizedArray;
}
if (isFile(value) || isBlob(value)) {
return undefined;
}
if (valueType === 'object' && value !== null) {
const entries = Object.entries(value as Record<string, unknown>);
const sanitizedEntries: Record<string, SerializableValue> = {};
for (const [key, entryValue] of entries) {
const sanitized = sanitizeValue(entryValue);
if (sanitized !== undefined) {
sanitizedEntries[key] = sanitized;
}
}
return sanitizedEntries;
}
return undefined;
}
function readStorage(): Record<string, SerializableValue> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
return parsed as Record<string, SerializableValue>;
}
} catch (error) {
console.error('[toolParameterStorage] Failed to read stored parameters', error);
}
return {};
}
function writeStorage(map: Record<string, SerializableValue>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch (error) {
console.error('[toolParameterStorage] Failed to write stored parameters', error);
}
}
export function loadToolParameters<T>(toolKey: string): Partial<T> | null {
const storage = readStorage();
const stored = storage[toolKey];
if (!stored || typeof stored !== 'object') {
return null;
}
return stored as Partial<T>;
}
export function saveToolParameters<T>(toolKey: string, parameters: T): void {
const sanitized = sanitizeValue(parameters);
if (sanitized === undefined) {
return;
}
const storage = readStorage();
storage[toolKey] = sanitized;
writeStorage(storage);
}
export function clearToolParameters(toolKey: string): void {
const storage = readStorage();
if (Object.prototype.hasOwnProperty.call(storage, toolKey)) {
delete storage[toolKey];
writeStorage(storage);
}
}
export function clearAllToolParameters(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error('[toolParameterStorage] Failed to clear stored parameters', error);
}
}
@@ -1,6 +1,7 @@
import { ReactNode } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
/**
@@ -22,6 +23,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
<DesktopConfigSync />
<DesktopBannerInitializer />
{children}
</ProprietaryAppProviders>
);
@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(<DefaultAppBanner />);
}, [setBanner]);
return null;
}
@@ -0,0 +1,27 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
export const DefaultAppBanner: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
const [dismissed, setDismissed] = useState(false);
const handleDismissPrompt = () => {
setDismissed(true);
};
return (
<InfoBanner
icon="picture-as-pdf-rounded"
message={t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')}
buttonText={t('defaultApp.setDefault', 'Set Default')}
buttonIcon="check-circle-rounded"
onButtonClick={handleSetDefault}
onDismiss={handleDismissPrompt}
loading={isLoading}
show={!dismissed && isDefault === false}
/>
);
};
@@ -0,0 +1,40 @@
import React from 'react';
import { Paper, Text, Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
export const DefaultAppSettings: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
return (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultPdfEditor', 'Default PDF editor')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{isDefault === true
? t('settings.general.defaultPdfEditorActive', 'Stirling PDF is your default PDF editor')
: isDefault === false
? t('settings.general.defaultPdfEditorInactive', 'Another application is set as default')
: t('settings.general.defaultPdfEditorChecking', 'Checking...')}
</Text>
</div>
<Button
variant={isDefault ? 'light' : 'filled'}
color="blue"
size="sm"
onClick={handleSetDefault}
loading={isLoading}
disabled={isDefault === true}
>
{isDefault
? t('settings.general.defaultPdfEditorSet', 'Already Default')
: t('settings.general.setAsDefault', 'Set as Default')}
</Button>
</Group>
</Paper>
);
};
@@ -0,0 +1,18 @@
import React from 'react';
import { Stack } from '@mantine/core';
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
import { DefaultAppSettings } from '@app/components/shared/config/configSections/DefaultAppSettings';
/**
* Desktop extension of GeneralSection that adds default PDF editor settings
*/
const GeneralSection: React.FC = () => {
return (
<Stack gap="lg">
<DefaultAppSettings />
<CoreGeneralSection />
</Stack>
);
};
export default GeneralSection;
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { useOpenedFile } from '@app/hooks/useOpenedFile';
import { fileOpenService } from '@app/services/fileOpenService';
@@ -20,75 +20,42 @@ export function useAppInitialization(): void {
// Handle files opened with app (Tauri mode)
const { openedFilePaths, loading: openedFileLoading } = useOpenedFile();
// Track if we've already loaded the initial files to prevent duplicate loads
const initialFilesLoadedRef = useRef(false);
// Load opened files and add directly to FileContext
useEffect(() => {
if (openedFilePaths.length > 0 && !openedFileLoading && !initialFilesLoadedRef.current) {
initialFilesLoadedRef.current = true;
const loadOpenedFiles = async () => {
try {
const filesArray: File[] = [];
// Load all files in parallel
await Promise.all(
openedFilePaths.map(async (filePath) => {
try {
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
if (fileData) {
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
filesArray.push(file);
console.log('[Desktop] Loaded file:', fileData.fileName);
}
} catch (error) {
console.error('[Desktop] Failed to load file:', filePath, error);
}
})
);
if (filesArray.length > 0) {
// Add all files to FileContext at once
await addFiles(filesArray);
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
}
} catch (error) {
console.error('[Desktop] Failed to load opened files:', error);
}
};
loadOpenedFiles();
if (openedFilePaths.length === 0 || openedFileLoading) {
return;
}
}, [openedFilePaths, openedFileLoading, addFiles]);
// Listen for runtime file-opened events (from second instances on Windows/Linux)
useEffect(() => {
const handleRuntimeFileOpen = async (filePath: string) => {
const loadOpenedFiles = async () => {
try {
console.log('[Desktop] Runtime file-opened event received:', filePath);
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
if (fileData) {
// Create a File object from the ArrayBuffer
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
const filesArray: File[] = [];
// Add directly to FileContext
await addFiles([file]);
console.log('[Desktop] Runtime opened file added to FileContext:', fileData.fileName);
await Promise.all(
openedFilePaths.map(async (filePath) => {
try {
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
if (fileData) {
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
filesArray.push(file);
console.log('[Desktop] Loaded file:', fileData.fileName);
}
} catch (error) {
console.error('[Desktop] Failed to load file:', filePath, error);
}
})
);
if (filesArray.length > 0) {
await addFiles(filesArray);
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
}
} catch (error) {
console.error('[Desktop] Failed to load runtime opened file:', error);
console.error('[Desktop] Failed to load opened files:', error);
}
};
// Set up event listener and get cleanup function
const unlisten = fileOpenService.onFileOpened(handleRuntimeFileOpen);
// Clean up listener on unmount
return unlisten;
}, [addFiles]);
loadOpenedFiles();
}, [openedFilePaths, openedFileLoading, addFiles]);
}
@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { defaultAppService } from '@app/services/defaultAppService';
import { alert } from '@app/components/toast';
export const useDefaultApp = () => {
const { t } = useTranslation();
const [isDefault, setIsDefault] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
checkDefaultStatus();
}, []);
const checkDefaultStatus = async () => {
try {
const status = await defaultAppService.isDefaultPdfHandler();
setIsDefault(status);
} catch (error) {
console.error('Failed to check default status:', error);
}
};
const handleSetDefault = async () => {
setIsLoading(true);
try {
const result = await defaultAppService.setAsDefaultPdfHandler();
if (result === 'set_successfully') {
alert({
alertType: 'success',
title: t('defaultApp.success.title', 'Default App Set'),
body: t('defaultApp.success.message', 'Stirling PDF is now your default PDF editor'),
});
setIsDefault(true);
} else if (result === 'opened_settings') {
alert({
alertType: 'neutral',
title: t('defaultApp.settingsOpened.title', 'Settings Opened'),
body: t('defaultApp.settingsOpened.message', 'Please select Stirling PDF in your system settings'),
});
}
} catch (error) {
console.error('Failed to set default:', error);
alert({
alertType: 'error',
title: t('defaultApp.error.title', 'Error'),
body: t('defaultApp.error.message', 'Failed to set default PDF handler'),
});
} finally {
setIsLoading(false);
}
};
return {
isDefault,
isLoading,
checkDefaultStatus,
handleSetDefault,
};
};
+16 -15
View File
@@ -1,45 +1,46 @@
import { useState, useEffect } from 'react';
import { fileOpenService } from '@app/services/fileOpenService';
import { listen } from '@tauri-apps/api/event';
export function useOpenedFile() {
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkForOpenedFile = async () => {
console.log('🔍 Checking for opened file(s)...');
// Function to read and process files from storage
const readFilesFromStorage = async () => {
console.log('🔍 Reading files from storage...');
try {
const filePaths = await fileOpenService.getOpenedFiles();
console.log('🔍 fileOpenService.getOpenedFiles() returned:', filePaths);
if (filePaths.length > 0) {
console.log(`✅ App opened with ${filePaths.length} file(s):`, filePaths);
console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths);
setOpenedFilePaths(filePaths);
// Clear the files from service state after consuming them
await fileOpenService.clearOpenedFiles();
} else {
console.log('ℹ️ No files were opened with the app');
}
} catch (error) {
console.error('❌ Failed to check for opened files:', error);
console.error('❌ Failed to read files from storage:', error);
} finally {
setLoading(false);
}
};
checkForOpenedFile();
// Read files on mount
readFilesFromStorage();
// Listen for runtime file open events (abstracted through service)
const unlistenRuntimeEvents = fileOpenService.onFileOpened((filePath: string) => {
console.log('📂 Runtime file open event:', filePath);
setOpenedFilePaths(prev => [...prev, filePath]);
// Listen for files-changed events (when new files are added to storage)
let unlisten: (() => void) | undefined;
listen('files-changed', async () => {
console.log('📂 files-changed event received, re-reading storage...');
await readFilesFromStorage();
}).then(unlistenFn => {
unlisten = unlistenFn;
});
// Cleanup function
return () => {
unlistenRuntimeEvents();
if (unlisten) unlisten();
};
}, []);
@@ -0,0 +1,70 @@
import { invoke } from '@tauri-apps/api/core';
/**
* Service for managing default PDF handler settings
* Note: Uses localStorage for machine-specific preferences (not synced to server)
*/
export const defaultAppService = {
/**
* Check if Stirling PDF is the default PDF handler
*/
async isDefaultPdfHandler(): Promise<boolean> {
try {
const result = await invoke<boolean>('is_default_pdf_handler');
return result;
} catch (error) {
console.error('[DefaultApp] Failed to check default handler:', error);
return false;
}
},
/**
* Set or prompt to set Stirling PDF as default PDF handler
* Returns a status string indicating what happened
*/
async setAsDefaultPdfHandler(): Promise<'set_successfully' | 'opened_settings' | 'error'> {
try {
const result = await invoke<string>('set_as_default_pdf_handler');
return result as 'set_successfully' | 'opened_settings';
} catch (error) {
console.error('[DefaultApp] Failed to set default handler:', error);
return 'error';
}
},
/**
* Check if user has dismissed the default app prompt (machine-specific)
*/
hasUserDismissedPrompt(): boolean {
try {
const dismissed = localStorage.getItem('stirlingpdf_default_app_prompt_dismissed');
return dismissed === 'true';
} catch {
return false;
}
},
/**
* Mark that user has dismissed the default app prompt (machine-specific)
*/
setPromptDismissed(dismissed: boolean): void {
try {
localStorage.setItem('stirlingpdf_default_app_prompt_dismissed', dismissed ? 'true' : 'false');
} catch (error) {
console.error('[DefaultApp] Failed to save prompt preference:', error);
}
},
/**
* Check if we should show the default app prompt
* Returns true if: user hasn't dismissed it AND app is not default handler
*/
async shouldShowPrompt(): Promise<boolean> {
if (this.hasUserDismissedPrompt()) {
return false;
}
const isDefault = await this.isDefaultPdfHandler();
return !isDefault;
},
};
+13 -10
View File
@@ -1,6 +1,7 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
@@ -22,17 +23,19 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
</AppProviders>
</Suspense>
);
@@ -107,7 +107,7 @@ class SpringAuthClient {
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return value;
return decodeURIComponent(value);
}
}
return null;
@@ -251,7 +251,7 @@ class SpringAuthClient {
* This redirects to the Spring OAuth2 authorization endpoint
*/
async signInWithOAuth(params: {
provider: 'github' | 'google' | 'apple' | 'azure';
provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc';
options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> {
try {
@@ -278,7 +278,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/logout', null, {
headers: {
'X-CSRF-TOKEN': this.getCsrfToken() || '',
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
@@ -311,7 +311,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/refresh', null, {
headers: {
'X-CSRF-TOKEN': this.getCsrfToken() || '',
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
@@ -0,0 +1,515 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Stack,
Text,
Button,
TextInput,
Select,
Paper,
Checkbox,
Textarea,
SegmentedControl,
Tooltip,
CloseButton,
Box,
Group,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { userManagementService } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface InviteMembersModalProps {
opened: boolean;
onClose: () => void;
}
export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const [teams, setTeams] = useState<Team[]>([]);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
maxAllowedUsers: number;
availableSlots: number;
grandfatheredUserCount: number;
licenseMaxUsers: number;
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
forceChange: false,
});
// Form state for email invite
const [emailInviteForm, setEmailInviteForm] = useState({
emails: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
});
// Form state for invite link
const [inviteLinkForm, setInviteLinkForm] = useState({
email: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
expiryHours: 72,
sendEmail: false,
});
// Fetch teams and license info
useEffect(() => {
if (opened) {
const fetchData = async () => {
try {
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
setTeams(teamsData);
setLicenseInfo({
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots,
grandfatheredUserCount: adminData.grandfatheredUserCount,
licenseMaxUsers: adminData.licenseMaxUsers,
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
} catch (error) {
console.error('Failed to fetch data:', error);
}
};
fetchData();
}
}, [opened]);
const roleOptions = [
{
value: 'ROLE_USER',
label: t('workspace.people.roleDescriptions.user', 'User'),
},
{
value: 'ROLE_ADMIN',
label: t('workspace.people.roleDescriptions.admin', 'Admin'),
},
];
const teamOptions = teams.map((team) => ({
value: team.id.toString(),
label: team.name,
}));
const handleInviteUser = async () => {
if (!inviteForm.username || !inviteForm.password) {
alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') });
return;
}
try {
setProcessing(true);
await userManagementService.createUser({
username: inviteForm.username,
password: inviteForm.password,
role: inviteForm.role,
teamId: inviteForm.teamId,
authType: 'password',
forceChange: inviteForm.forceChange,
});
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
onClose();
// Reset form
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined,
forceChange: false,
});
} catch (error: any) {
console.error('Failed to invite user:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.addMember.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleEmailInvite = async () => {
if (!emailInviteForm.emails.trim()) {
alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'Email addresses are required') });
return;
}
try {
setProcessing(true);
const response = await userManagementService.inviteUsers({
emails: emailInviteForm.emails, // comma-separated string as required by API
role: emailInviteForm.role,
teamId: emailInviteForm.teamId,
});
if (response.successCount > 0) {
alert({
alertType: 'success',
title: t('workspace.people.emailInvite.success', { count: response.successCount, defaultValue: `Successfully invited ${response.successCount} user(s)` })
});
onClose();
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
teamId: undefined,
});
} else {
alert({
alertType: 'error',
title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'),
body: response.errors || response.error
});
}
} catch (error: any) {
console.error('Failed to invite users:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.emailInvite.error', 'Failed to send invites');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleGenerateInviteLink = async () => {
try {
setProcessing(true);
const response = await userManagementService.generateInviteLink({
email: inviteLinkForm.email || undefined,
role: inviteLinkForm.role,
teamId: inviteLinkForm.teamId,
expiryHours: inviteLinkForm.expiryHours,
sendEmail: inviteLinkForm.sendEmail,
});
setGeneratedInviteLink(response.inviteUrl);
if (inviteLinkForm.sendEmail && inviteLinkForm.email) {
alert({ alertType: 'success', title: t('workspace.people.inviteLink.emailSent', 'Invite link generated and sent via email') });
}
} catch (error: any) {
console.error('Failed to generate invite link:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.inviteLink.error', 'Failed to generate invite link');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleClose = () => {
setGeneratedInviteLink(null);
setInviteMode('direct');
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined,
forceChange: false,
});
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
teamId: undefined,
});
setInviteLinkForm({
email: '',
role: 'ROLE_USER',
teamId: undefined,
expiryHours: 72,
sendEmail: false,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<Box pos="relative">
<CloseButton
onClick={handleClose}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.inviteMembers.label', 'Invite Members')}
</Text>
{inviteMode === 'email' && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
</Text>
)}
</Stack>
{/* License Warning/Info */}
{licenseInfo && (
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t('workspace.people.license.slotsAvailable', {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
})
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t('workspace.people.license.currentUsage', {
current: licenseInfo.totalUsers,
max: licenseInfo.maxAllowedUsers,
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
})}
</Text>
</Stack>
</Paper>
)}
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => {
setInviteMode(value as 'email' | 'direct' | 'link');
setGeneratedInviteLink(null);
}}
data={[
{
label: t('workspace.people.inviteMode.username', 'Username'),
value: 'direct',
},
{
label: t('workspace.people.inviteMode.link', 'Link'),
value: 'link',
},
{
label: t('workspace.people.inviteMode.email', 'Email'),
value: 'email',
disabled: !config?.enableEmailInvites,
},
]}
fullWidth
/>
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === 'link' && (
<>
<TextInput
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
placeholder={t('workspace.people.inviteLink.emailPlaceholder', 'user@example.com')}
value={inviteLinkForm.email}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<TextInput
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
checked={inviteLinkForm.sendEmail}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(generatedInviteLink);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement('textarea');
textArea.value = generatedInviteLink;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
}
}}
>
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
<Textarea
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', 'user1@example.com, user2@example.com')}
value={emailInviteForm.emails}
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
minRows={3}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={emailInviteForm.teamId?.toString()}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === 'direct' && (
<>
<TextInput
label={t('workspace.people.addMember.username')}
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
required
/>
<TextInput
label={t('workspace.people.addMember.password')}
type="password"
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteForm.role}
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteForm.teamId?.toString()}
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Checkbox
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
/>
</>
)}
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: inviteMode === 'link'
? t('workspace.people.inviteLink.submit', 'Generate Link')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</Box>
</Modal>
);
}
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { BASE_PATH } from '@app/constants/app';
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
export default function LoginRightCarousel({
function LoginRightCarousel({
imageSlides = [],
showBackground = true,
initialSeconds = 5,
@@ -157,3 +157,5 @@ export default function LoginRightCarousel({
</div>
);
}
export default memo(LoginRightCarousel);
@@ -2,41 +2,181 @@ import React from 'react';
import { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections';
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
/**
* Proprietary extension of createConfigNavSections that adds workspace sections
* Proprietary extension of createConfigNavSections that adds all admin and workspace sections
*/
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
): ConfigNavSection[] => {
// Get the core sections
const sections = createCoreConfigNavSections(isAdmin, runningEE);
// Get the core sections (just Preferences)
const sections = createCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add Workspace section if user is admin
if (isAdmin) {
const workspaceSection: ConfigNavSection = {
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;
// Workspace
sections.push({
title: 'Workspace',
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
component: <PeopleSection />
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
component: <TeamsSection />
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
};
});
// Insert workspace section after Preferences (at index 1)
sections.splice(1, 0, workspaceSection);
// Configuration
sections.push({
title: 'Configuration',
items: [
{
key: 'adminGeneral',
label: 'System Settings',
icon: 'settings-rounded',
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminFeatures',
label: 'Features',
icon: 'extension-rounded',
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
});
// Security & Authentication
sections.push({
title: 'Security & Authentication',
items: [
{
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
});
// Licensing & Analytics
sections.push({
title: 'Licensing & Analytics',
items: [
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminAudit',
label: 'Audit',
icon: 'fact-check-rounded',
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license'
},
{
key: 'adminUsage',
label: 'Usage Analytics',
icon: 'analytics-rounded',
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license'
},
],
});
// Policies & Privacy
sections.push({
title: 'Policies & Privacy',
items: [
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
});
}
// Add Developer section if login is enabled
@@ -7,6 +7,8 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface AdvancedSettingsData {
enableAlphaFunctionality?: boolean;
@@ -55,6 +57,7 @@ interface AdvancedSettingsData {
export default function AdminAdvancedSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const {
settings,
@@ -165,10 +168,15 @@ export default function AdminAdvancedSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@@ -181,7 +189,9 @@ export default function AdminAdvancedSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -191,6 +201,7 @@ export default function AdminAdvancedSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
<Text size="sm" c="dimmed">
@@ -213,7 +224,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.enableAlphaFunctionality || false}
onChange={(e) => setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableAlphaFunctionality: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableAlphaFunctionality')} />
</Group>
@@ -229,7 +245,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.enableUrlToPDF || false}
onChange={(e) => setSettings({ ...settings, enableUrlToPDF: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableUrlToPDF: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableUrlToPDF')} />
</Group>
@@ -245,7 +266,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.disableSanitize || false}
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, disableSanitize: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('disableSanitize')} />
</Group>
@@ -271,6 +297,7 @@ export default function AdminAdvancedSection() {
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
min={0}
max={3000}
disabled={!loginEnabled}
/>
</div>
@@ -286,6 +313,7 @@ export default function AdminAdvancedSection() {
value={settings.tessdataDir || ''}
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
placeholder="/usr/share/tessdata"
disabled={!loginEnabled}
/>
</div>
</Stack>
@@ -311,6 +339,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, baseTmpDir: e.target.value }
})}
placeholder="Default: java.io.tmpdir/stirling-pdf"
disabled={!loginEnabled}
/>
</div>
@@ -324,6 +353,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, libreofficeDir: e.target.value }
})}
placeholder="Default: baseTmpDir/libreoffice"
disabled={!loginEnabled}
/>
</div>
@@ -337,6 +367,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, systemTempDir: e.target.value }
})}
placeholder="System temp directory path"
disabled={!loginEnabled}
/>
</div>
@@ -350,6 +381,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, prefix: e.target.value }
})}
placeholder="stirling-pdf-"
disabled={!loginEnabled}
/>
</div>
@@ -364,6 +396,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={720}
disabled={!loginEnabled}
/>
</div>
@@ -378,6 +411,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={1440}
disabled={!loginEnabled}
/>
</div>
@@ -391,10 +425,15 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.tempFileManagement?.startupCleanup ?? true}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('tempFileManagement.startupCleanup')} />
</Group>
@@ -410,10 +449,15 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.tempFileManagement?.cleanupSystemTemp ?? false}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('tempFileManagement.cleanupSystemTemp')} />
</Group>
@@ -448,6 +492,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -462,6 +507,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -485,6 +531,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -499,6 +546,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -522,6 +570,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -536,6 +585,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -559,6 +609,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -573,6 +624,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -596,6 +648,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -610,6 +663,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -633,6 +687,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -647,6 +702,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -670,6 +726,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -684,6 +741,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -707,6 +765,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -721,6 +780,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -744,6 +804,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -758,6 +819,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -781,6 +843,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@@ -795,6 +858,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@@ -805,7 +869,7 @@ export default function AdminAdvancedSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -6,9 +6,12 @@ import AuditSystemStatus from '@app/components/shared/config/configSections/audi
import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
const AdminAuditSection: React.FC = () => {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -27,10 +30,24 @@ const AdminAuditSection: React.FC = () => {
}
};
fetchSystemStatus();
}, []);
if (loginEnabled) {
fetchSystemStatus();
} else {
// Provide example audit system status when login is disabled
setSystemStatus({
enabled: true,
level: 'INFO',
retentionDays: 90,
totalEvents: 1234,
});
setLoading(false);
}
}, [loginEnabled]);
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<Loader size="lg" />
@@ -56,32 +73,33 @@ const AdminAuditSection: React.FC = () => {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<AuditSystemStatus status={systemStatus} />
{systemStatus.enabled ? (
<Tabs defaultValue="dashboard">
<Tabs.List>
<Tabs.Tab value="dashboard">
<Tabs.Tab value="dashboard" disabled={!loginEnabled}>
{t('audit.tabs.dashboard', 'Dashboard')}
</Tabs.Tab>
<Tabs.Tab value="events">
<Tabs.Tab value="events" disabled={!loginEnabled}>
{t('audit.tabs.events', 'Audit Events')}
</Tabs.Tab>
<Tabs.Tab value="export">
<Tabs.Tab value="export" disabled={!loginEnabled}>
{t('audit.tabs.export', 'Export')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="dashboard" pt="md">
<AuditChartsSection />
<AuditChartsSection loginEnabled={loginEnabled} />
</Tabs.Panel>
<Tabs.Panel value="events" pt="md">
<AuditEventsTable />
<AuditEventsTable loginEnabled={loginEnabled} />
</Tabs.Panel>
<Tabs.Panel value="export" pt="md">
<AuditExportSection />
<AuditExportSection loginEnabled={loginEnabled} />
</Tabs.Panel>
</Tabs>
) : (
@@ -12,6 +12,8 @@ import {
Provider,
} from '@app/components/shared/config/configSections/providerDefinitions';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface ConnectionsSettingsData {
oauth2?: {
@@ -45,15 +47,10 @@ interface ConnectionsSettingsData {
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = useAdminSettings<ConnectionsSettingsData>({
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
sectionName: 'connections',
fetchTransformer: async () => {
// Fetch security settings (oauth2, saml2)
@@ -106,57 +103,75 @@ export default function AdminConnectionsSection() {
}
});
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = adminSettings;
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const isProviderConfigured = (provider: Provider): boolean => {
if (provider.id === 'saml2') {
return settings.saml2?.enabled === true;
return settings?.saml2?.enabled === true;
}
if (provider.id === 'smtp') {
return settings.mail?.enabled === true;
return settings?.mail?.enabled === true;
}
if (provider.id === 'oauth2-generic') {
return settings.oauth2?.enabled === true;
return settings?.oauth2?.enabled === true;
}
// Check if specific OAuth2 provider is configured (has clientId)
const providerSettings = settings.oauth2?.client?.[provider.id];
const providerSettings = settings?.oauth2?.client?.[provider.id];
return !!(providerSettings?.clientId);
};
const getProviderSettings = (provider: Provider): Record<string, any> => {
if (provider.id === 'saml2') {
return settings.saml2 || {};
return settings?.saml2 || {};
}
if (provider.id === 'smtp') {
return settings.mail || {};
return settings?.mail || {};
}
if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings are at the root oauth2 level
return {
enabled: settings.oauth2?.enabled,
provider: settings.oauth2?.provider,
issuer: settings.oauth2?.issuer,
clientId: settings.oauth2?.clientId,
clientSecret: settings.oauth2?.clientSecret,
scopes: settings.oauth2?.scopes,
useAsUsername: settings.oauth2?.useAsUsername,
autoCreateUser: settings.oauth2?.autoCreateUser,
blockRegistration: settings.oauth2?.blockRegistration,
enabled: settings?.oauth2?.enabled,
provider: settings?.oauth2?.provider,
issuer: settings?.oauth2?.issuer,
clientId: settings?.oauth2?.clientId,
clientSecret: settings?.oauth2?.clientSecret,
scopes: settings?.oauth2?.scopes,
useAsUsername: settings?.oauth2?.useAsUsername,
autoCreateUser: settings?.oauth2?.autoCreateUser,
blockRegistration: settings?.oauth2?.blockRegistration,
};
}
// Specific OAuth2 provider settings
return settings.oauth2?.client?.[provider.id] || {};
return settings?.oauth2?.client?.[provider.id] || {};
};
const handleProviderSave = async (provider: Provider, providerSettings: Record<string, any>) => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
@@ -218,7 +233,12 @@ export default function AdminConnectionsSection() {
};
const handleProviderDisconnect = async (provider: Provider) => {
try {
// Block disconnect if login is disabled
if (!validateLoginEnabled()) {
return;
}
try{
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false });
@@ -271,7 +291,7 @@ export default function AdminConnectionsSection() {
}
};
if (loading) {
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -280,9 +300,14 @@ export default function AdminConnectionsSection() {
}
const handleSSOAutoLoginSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
const deltaSettings = {
'premium.proFeatures.ssoAutoLogin': settings.ssoAutoLogin
'premium.proFeatures.ssoAutoLogin': settings?.ssoAutoLogin
};
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
@@ -311,6 +336,8 @@ export default function AdminConnectionsSection() {
return (
<Stack gap="xl">
<LoginRequiredBanner show={!loginEnabled} />
{/* Header */}
<div>
<Text fw={600} size="lg">
@@ -341,11 +368,14 @@ export default function AdminConnectionsSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.ssoAutoLogin || false}
checked={settings?.ssoAutoLogin || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
handleSSOAutoLoginSave();
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('ssoAutoLogin')} />
</Group>
@@ -369,6 +399,7 @@ export default function AdminConnectionsSection() {
settings={getProviderSettings(provider)}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
onDisconnect={() => handleProviderDisconnect(provider)}
disabled={!loginEnabled}
/>
))}
</Stack>
@@ -392,6 +423,7 @@ export default function AdminConnectionsSection() {
provider={provider}
isConfigured={false}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
disabled={!loginEnabled}
/>
))}
</Stack>
@@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import apiClient from '@app/services/apiClient';
interface DatabaseSettingsData {
@@ -21,6 +23,7 @@ interface DatabaseSettingsData {
export default function AdminDatabaseSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -78,10 +81,16 @@ export default function AdminDatabaseSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@@ -94,7 +103,10 @@ export default function AdminDatabaseSection() {
}
};
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -104,6 +116,8 @@ export default function AdminDatabaseSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group justify="space-between" align="center">
<div>
@@ -130,14 +144,19 @@ export default function AdminDatabaseSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.enableCustomDatabase || false}
onChange={(e) => setSettings({ ...settings, enableCustomDatabase: e.target.checked })}
checked={settings?.enableCustomDatabase || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableCustomDatabase: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableCustomDatabase')} />
</Group>
</div>
{settings.enableCustomDatabase && (
{settings?.enableCustomDatabase && (
<>
<div>
<TextInput
@@ -148,9 +167,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.customUrl.description', 'Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.')}
value={settings.customDatabaseUrl || ''}
value={settings?.customDatabaseUrl || ''}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
disabled={!loginEnabled}
/>
</div>
@@ -163,7 +183,7 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.type.description', 'Type of database (not used if custom URL is provided)')}
value={settings.type || 'postgresql'}
value={settings?.type || 'postgresql'}
onChange={(value) => setSettings({ ...settings, type: value || 'postgresql' })}
data={[
{ value: 'postgresql', label: 'PostgreSQL' },
@@ -171,6 +191,7 @@ export default function AdminDatabaseSection() {
{ value: 'mysql', label: 'MySQL' },
{ value: 'mariadb', label: 'MariaDB' }
]}
disabled={!loginEnabled}
/>
</div>
@@ -183,9 +204,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.hostName.description', 'Database server hostname (not used if custom URL is provided)')}
value={settings.hostName || ''}
value={settings?.hostName || ''}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
disabled={!loginEnabled}
/>
</div>
@@ -198,10 +220,11 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.port.description', 'Database server port (not used if custom URL is provided)')}
value={settings.port || 5432}
value={settings?.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
disabled={!loginEnabled}
/>
</div>
@@ -214,9 +237,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.name.description', 'Name of the database (not used if custom URL is provided)')}
value={settings.name || ''}
value={settings?.name || ''}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
@@ -229,9 +253,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.username.description', 'Database authentication username')}
value={settings.username || ''}
value={settings?.username || ''}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
@@ -244,9 +269,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.password.description', 'Database authentication password')}
value={settings.password || ''}
value={settings?.password || ''}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
placeholder="••••••••"
disabled={!loginEnabled}
/>
</div>
</>
@@ -256,7 +282,7 @@ export default function AdminDatabaseSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface EndpointsSettingsData {
toRemove?: string[];
@@ -14,6 +16,7 @@ interface EndpointsSettingsData {
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -29,10 +32,16 @@ export default function AdminEndpointsSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@@ -45,7 +54,10 @@ export default function AdminEndpointsSection() {
}
};
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -102,6 +114,8 @@ export default function AdminEndpointsSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
<Text size="sm" c="dimmed">
@@ -123,12 +137,16 @@ export default function AdminEndpointsSection() {
}
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
value={settings.toRemove || []}
onChange={(value) => setSettings({ ...settings, toRemove: value })}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, toRemove: value });
}}
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@@ -142,12 +160,16 @@ export default function AdminEndpointsSection() {
}
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
value={settings.groupsToRemove || []}
onChange={(value) => setSettings({ ...settings, groupsToRemove: value })}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, groupsToRemove: value });
}}
data={commonGroups.map(group => ({ value: group, label: group }))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@@ -160,7 +182,7 @@ export default function AdminEndpointsSection() {
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -7,6 +7,8 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface FeaturesSettingsData {
serverCertificate?: {
@@ -19,6 +21,7 @@ interface FeaturesSettingsData {
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -69,10 +72,15 @@ export default function AdminFeaturesSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@@ -85,7 +93,9 @@ export default function AdminFeaturesSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -95,6 +105,7 @@ export default function AdminFeaturesSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
<Text size="sm" c="dimmed">
@@ -124,10 +135,15 @@ export default function AdminFeaturesSection() {
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('serverCertificate.enabled')} />
</Group>
@@ -148,6 +164,7 @@ export default function AdminFeaturesSection() {
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value }
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
@@ -167,6 +184,7 @@ export default function AdminFeaturesSection() {
})}
min={1}
max={3650}
disabled={!loginEnabled}
/>
</div>
@@ -180,10 +198,15 @@ export default function AdminFeaturesSection() {
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('serverCertificate.regenerateOnStartup')} />
</Group>
@@ -193,7 +216,7 @@ export default function AdminFeaturesSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -1,17 +1,20 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge } from '@mantine/core';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface GeneralSettingsData {
ui: {
appNameNavbar?: string;
languages?: string[];
logoStyle?: 'modern' | 'classic';
};
system: {
defaultLocale?: string;
@@ -40,6 +43,7 @@ interface GeneralSettingsData {
export default function AdminGeneralSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -108,14 +112,15 @@ export default function AdminGeneralSection() {
saveTransformer: (settings) => {
const deltaSettings: Record<string, any> = {
// UI settings
'ui.appNameNavbar': settings.ui.appNameNavbar,
'ui.languages': settings.ui.languages,
'ui.appNameNavbar': settings.ui?.appNameNavbar,
'ui.languages': settings.ui?.languages,
'ui.logoStyle': settings.ui?.logoStyle,
// System settings
'system.defaultLocale': settings.system.defaultLocale,
'system.showUpdate': settings.system.showUpdate,
'system.showUpdateOnlyAdmin': settings.system.showUpdateOnlyAdmin,
'system.customHTMLFiles': settings.system.customHTMLFiles,
'system.fileUploadLimit': settings.system.fileUploadLimit,
'system.defaultLocale': settings.system?.defaultLocale,
'system.showUpdate': settings.system?.showUpdate,
'system.showUpdateOnlyAdmin': settings.system?.showUpdateOnlyAdmin,
'system.customHTMLFiles': settings.system?.customHTMLFiles,
'system.fileUploadLimit': settings.system?.fileUploadLimit,
// Premium custom metadata
'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata,
'premium.proFeatures.customMetadata.author': settings.customMetadata?.author,
@@ -124,10 +129,10 @@ export default function AdminGeneralSection() {
};
if (settings.customPaths) {
deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths.pipeline?.watchedFoldersDir;
deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths.pipeline?.finishedFoldersDir;
deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths.operations?.weasyprint;
deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths.operations?.unoconvert;
deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths?.pipeline?.watchedFoldersDir;
deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths?.pipeline?.finishedFoldersDir;
deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths?.operations?.weasyprint;
deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths?.operations?.unoconvert;
}
return {
@@ -138,10 +143,21 @@ export default function AdminGeneralSection() {
});
useEffect(() => {
fetchSettings();
}, []);
// Only fetch real settings if login is enabled
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const handleSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@@ -154,7 +170,7 @@ export default function AdminGeneralSection() {
}
};
if (loading) {
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -164,6 +180,8 @@ export default function AdminGeneralSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.general.title', 'System Settings')}</Text>
<Text size="sm" c="dimmed">
@@ -185,9 +203,55 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.appNameNavbar.description', 'The name displayed in the navigation bar')}
value={settings.ui.appNameNavbar || ''}
value={settings.ui?.appNameNavbar || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appNameNavbar: e.target.value } })}
placeholder="Stirling PDF"
disabled={!loginEnabled}
/>
</div>
<div>
<Text size="sm" fw={500} mb={4}>
<Group gap="xs">
<span>{t('admin.settings.general.logoStyle.label', 'Logo Style')}</span>
<PendingBadge show={isFieldPending('ui.logoStyle')} />
</Group>
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t('admin.settings.general.logoStyle.description', 'Choose between the modern minimalist logo or the classic S icon')}
</Text>
<SegmentedControl
value={settings.ui?.logoStyle || 'classic'}
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, logoStyle: value as 'modern' | 'classic' } })}
data={[
{
value: 'classic',
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0' }}>
<img
src="/branding/old/favicon.svg"
alt="Classic logo"
style={{ width: '24px', height: '24px' }}
/>
<span>{t('admin.settings.general.logoStyle.classic', 'Classic')}</span>
</div>
)
},
{
value: 'modern',
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0' }}>
<img
src="/branding/StirlingPDFLogoNoTextLight.svg"
alt="Modern logo"
style={{ width: '24px', height: '24px' }}
/>
<span>{t('admin.settings.general.logoStyle.modern', 'Modern')}</span>
</div>
)
},
]}
disabled={!loginEnabled}
/>
</div>
@@ -200,7 +264,7 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')}
value={settings.ui.languages || []}
value={settings.ui?.languages || []}
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })}
data={[
{ value: 'de_DE', label: 'Deutsch' },
@@ -218,6 +282,7 @@ export default function AdminGeneralSection() {
clearable
placeholder="Select languages"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@@ -230,9 +295,10 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')}
value={settings.system.defaultLocale || ''}
value={ settings.system?.defaultLocale || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })}
placeholder="en_US"
disabled={!loginEnabled}
/>
</div>
@@ -245,9 +311,10 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.fileUploadLimit.description', 'Maximum file upload size (e.g., 100MB, 1GB)')}
value={settings.system.fileUploadLimit || ''}
value={ settings.system?.fileUploadLimit || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, fileUploadLimit: e.target.value } })}
placeholder="100MB"
disabled={!loginEnabled}
/>
</div>
@@ -260,8 +327,9 @@ export default function AdminGeneralSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdate || false}
checked={ settings.system?.showUpdate || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdate: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('system.showUpdate')} />
</Group>
@@ -276,8 +344,9 @@ export default function AdminGeneralSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdateOnlyAdmin || false}
checked={ settings.system?.showUpdateOnlyAdmin || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdateOnlyAdmin: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('system.showUpdateOnlyAdmin')} />
</Group>
@@ -292,8 +361,9 @@ export default function AdminGeneralSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.system.customHTMLFiles || false}
checked={settings.system?.customHTMLFiles || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, customHTMLFiles: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('system.customHTMLFiles')} />
</Group>
@@ -326,6 +396,7 @@ export default function AdminGeneralSection() {
autoUpdateMetadata: e.target.checked
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('customMetadata.autoUpdateMetadata')} />
</Group>
@@ -349,6 +420,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="username"
disabled={!loginEnabled}
/>
</div>
@@ -370,6 +442,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
@@ -391,6 +464,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
</Stack>
@@ -429,6 +503,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/pipeline/watchedFolders"
disabled={!loginEnabled}
/>
</div>
@@ -453,6 +528,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/pipeline/finishedFolders"
disabled={!loginEnabled}
/>
</div>
@@ -479,6 +555,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/opt/venv/bin/weasyprint"
disabled={!loginEnabled}
/>
</div>
@@ -503,6 +580,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/opt/venv/bin/unoconvert"
disabled={!loginEnabled}
/>
</div>
</Stack>
@@ -510,7 +588,7 @@ export default function AdminGeneralSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -7,6 +7,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface LegalSettingsData {
termsAndConditions?: string;
@@ -18,6 +20,7 @@ interface LegalSettingsData {
export default function AdminLegalSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -33,10 +36,15 @@ export default function AdminLegalSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@@ -49,7 +57,9 @@ export default function AdminLegalSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -59,6 +69,7 @@ export default function AdminLegalSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
<Text size="sm" c="dimmed">
@@ -95,6 +106,7 @@ export default function AdminLegalSection() {
value={settings.termsAndConditions || ''}
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
placeholder="https://example.com/terms"
disabled={!loginEnabled}
/>
</div>
@@ -110,6 +122,7 @@ export default function AdminLegalSection() {
value={settings.privacyPolicy || ''}
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
placeholder="https://example.com/privacy"
disabled={!loginEnabled}
/>
</div>
@@ -125,6 +138,7 @@ export default function AdminLegalSection() {
value={settings.accessibilityStatement || ''}
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
placeholder="https://example.com/accessibility"
disabled={!loginEnabled}
/>
</div>
@@ -140,6 +154,7 @@ export default function AdminLegalSection() {
value={settings.cookiePolicy || ''}
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
placeholder="https://example.com/cookies"
disabled={!loginEnabled}
/>
</div>
@@ -155,13 +170,14 @@ export default function AdminLegalSection() {
value={settings.impressum || ''}
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
placeholder="https://example.com/impressum"
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -7,6 +7,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface PremiumSettingsData {
key?: string;
@@ -15,6 +17,7 @@ interface PremiumSettingsData {
export default function AdminPremiumSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -30,10 +33,15 @@ export default function AdminPremiumSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@@ -46,7 +54,9 @@ export default function AdminPremiumSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -56,6 +66,7 @@ export default function AdminPremiumSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
<Text size="sm" c="dimmed">
@@ -98,6 +109,7 @@ export default function AdminPremiumSection() {
value={settings.key || ''}
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={!loginEnabled}
/>
</div>
@@ -111,7 +123,12 @@ export default function AdminPremiumSection() {
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
@@ -120,7 +137,7 @@ export default function AdminPremiumSection() {
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import apiClient from '@app/services/apiClient';
interface PrivacySettingsData {
@@ -16,6 +18,7 @@ interface PrivacySettingsData {
export default function AdminPrivacySection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -76,10 +79,16 @@ export default function AdminPrivacySection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@@ -92,7 +101,10 @@ export default function AdminPrivacySection() {
}
};
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -102,6 +114,8 @@ export default function AdminPrivacySection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.privacy.title', 'Privacy')}</Text>
<Text size="sm" c="dimmed">
@@ -123,8 +137,13 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.enableAnalytics || false}
onChange={(e) => setSettings({ ...settings, enableAnalytics: e.target.checked })}
checked={settings?.enableAnalytics || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableAnalytics: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableAnalytics')} />
</Group>
@@ -139,8 +158,13 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.metricsEnabled || false}
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
checked={settings?.metricsEnabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, metricsEnabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('metricsEnabled')} />
</Group>
@@ -162,8 +186,13 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.googleVisibility || false}
onChange={(e) => setSettings({ ...settings, googleVisibility: e.target.checked })}
checked={settings?.googleVisibility || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, googleVisibility: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('googleVisibility')} />
</Group>
@@ -173,7 +202,7 @@ export default function AdminPrivacySection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -8,6 +8,8 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface SecuritySettingsData {
enableLogin?: boolean;
@@ -44,6 +46,7 @@ interface SecuritySettingsData {
export default function AdminSecuritySection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -157,10 +160,20 @@ export default function AdminSecuritySection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const handleSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@@ -173,7 +186,7 @@ export default function AdminSecuritySection() {
}
};
if (loading) {
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@@ -183,6 +196,8 @@ export default function AdminSecuritySection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.security.title', 'Security')}</Text>
<Text size="sm" c="dimmed">
@@ -204,8 +219,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.enableLogin || false}
checked={settings?.enableLogin || false}
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('enableLogin')} />
</Group>
@@ -215,7 +231,7 @@ export default function AdminSecuritySection() {
<Select
label={t('admin.settings.security.loginMethod.label', 'Login Method')}
description={t('admin.settings.security.loginMethod.description', 'The authentication method to use for user login')}
value={settings.loginMethod || 'all'}
value={settings?.loginMethod || 'all'}
onChange={(value) => setSettings({ ...settings, loginMethod: value || 'all' })}
data={[
{ value: 'all', label: t('admin.settings.security.loginMethod.all', 'All Methods') },
@@ -224,6 +240,7 @@ export default function AdminSecuritySection() {
{ value: 'saml2', label: t('admin.settings.security.loginMethod.saml2', 'SAML2 Only') },
]}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
{isFieldPending('loginMethod') && (
<Group mt="xs">
@@ -241,10 +258,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')}
value={settings.loginAttemptCount || 0}
value={settings?.loginAttemptCount || 0}
onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })}
min={0}
max={100}
disabled={!loginEnabled}
/>
</div>
@@ -257,10 +275,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')}
value={settings.loginResetTimeMinutes || 0}
value={settings?.loginResetTimeMinutes || 0}
onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })}
min={0}
max={1440}
disabled={!loginEnabled}
/>
</div>
@@ -273,8 +292,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.csrfDisabled || false}
checked={settings?.csrfDisabled || false}
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('csrfDisabled')} />
</Group>
@@ -308,8 +328,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
checked={settings?.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, persistence: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.persistence')} />
</Group>
@@ -324,8 +345,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
checked={settings?.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, enableKeyRotation: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyRotation')} />
</Group>
@@ -340,8 +362,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
checked={settings?.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, enableKeyCleanup: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyCleanup')} />
</Group>
@@ -356,10 +379,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
value={settings.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })}
value={settings?.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, keyRetentionDays: Number(value) } })}
min={1}
max={365}
disabled={!loginEnabled}
/>
</div>
@@ -372,8 +396,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })}
checked={settings?.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, secureCookie: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.secureCookie')} />
</Group>
@@ -398,8 +423,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.audit?.enabled || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })}
checked={settings?.audit?.enabled || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, enabled: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('audit.enabled')} />
</Group>
@@ -414,10 +440,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
value={settings.audit?.level || 2}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, level: Number(value) } })}
value={settings?.audit?.level || 2}
onChange={(value) => setSettings({ ...settings, audit: { ...settings?.audit, level: Number(value) } })}
min={0}
max={3}
disabled={!loginEnabled}
/>
</div>
@@ -430,10 +457,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.audit.retentionDays.description', 'Number of days to retain audit logs')}
value={settings.audit?.retentionDays || 90}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, retentionDays: Number(value) } })}
value={settings?.audit?.retentionDays || 90}
onChange={(value) => setSettings({ ...settings, audit: { ...settings?.audit, retentionDays: Number(value) } })}
min={1}
max={3650}
disabled={!loginEnabled}
/>
</div>
</Stack>
@@ -458,14 +486,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.enabled || false}
checked={settings?.html?.urlSecurity?.enabled || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, enabled: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.enabled')} />
</Group>
@@ -480,12 +509,12 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.level.description', 'MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions')}
value={settings.html?.urlSecurity?.level || 'MEDIUM'}
value={settings?.html?.urlSecurity?.level || 'MEDIUM'}
onChange={(value) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, level: value || 'MEDIUM' }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, level: value || 'MEDIUM' }
}
})}
data={[
@@ -494,6 +523,7 @@ export default function AdminSecuritySection() {
{ value: 'OFF', label: t('admin.settings.security.htmlUrlSecurity.level.off', 'Off (No Restrictions)') },
]}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@@ -512,13 +542,13 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.allowedDomains.description', 'One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX')}
value={settings.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
value={settings?.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
...settings?.html,
urlSecurity: {
...settings.html?.urlSecurity,
...settings?.html?.urlSecurity,
allowedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
@@ -526,6 +556,7 @@ export default function AdminSecuritySection() {
placeholder="cdn.example.com&#10;images.google.com"
minRows={3}
autosize
disabled={!loginEnabled}
/>
</div>
@@ -539,13 +570,13 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.blockedDomains.description', 'One domain per line (e.g., malicious.com). Additional domains to block')}
value={settings.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
value={settings?.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
...settings?.html,
urlSecurity: {
...settings.html?.urlSecurity,
...settings?.html?.urlSecurity,
blockedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
@@ -553,6 +584,7 @@ export default function AdminSecuritySection() {
placeholder="malicious.com&#10;evil.org"
minRows={3}
autosize
disabled={!loginEnabled}
/>
</div>
@@ -566,13 +598,13 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.internalTlds.description', 'One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns')}
value={settings.html?.urlSecurity?.internalTlds?.join('\n') || ''}
value={settings?.html?.urlSecurity?.internalTlds?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
...settings?.html,
urlSecurity: {
...settings.html?.urlSecurity,
...settings?.html?.urlSecurity,
internalTlds: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
@@ -580,6 +612,7 @@ export default function AdminSecuritySection() {
placeholder=".local&#10;.internal&#10;.corp&#10;.home"
minRows={3}
autosize
disabled={!loginEnabled}
/>
</div>
@@ -595,14 +628,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
checked={settings?.html?.urlSecurity?.blockPrivateNetworks || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockPrivateNetworks')} />
</Group>
@@ -617,14 +651,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLocalhost || false}
checked={settings?.html?.urlSecurity?.blockLocalhost || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockLocalhost: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLocalhost')} />
</Group>
@@ -639,14 +674,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
checked={settings?.html?.urlSecurity?.blockLinkLocal || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockLinkLocal: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLinkLocal')} />
</Group>
@@ -661,14 +697,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
checked={settings?.html?.urlSecurity?.blockCloudMetadata || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockCloudMetadata: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockCloudMetadata')} />
</Group>
@@ -682,7 +719,7 @@ export default function AdminSecuritySection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -14,9 +14,12 @@ import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
const AdminUsageSection: React.FC = () => {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -24,6 +27,10 @@ const AdminUsageSection: React.FC = () => {
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
const fetchData = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
setLoading(true);
setError(null);
@@ -40,10 +47,55 @@ const AdminUsageSection: React.FC = () => {
};
useEffect(() => {
fetchData();
}, [displayMode, dataType]);
if (loginEnabled) {
fetchData();
} else {
// Provide example usage analytics data when login is disabled
const totalVisits = 15847;
const allEndpoints = [
{ endpoint: 'merge-pdfs', visits: 3245, percentage: (3245 / totalVisits) * 100 },
{ endpoint: 'compress-pdf', visits: 2891, percentage: (2891 / totalVisits) * 100 },
{ endpoint: 'pdf-to-img', visits: 2156, percentage: (2156 / totalVisits) * 100 },
{ endpoint: 'split-pdf', visits: 1834, percentage: (1834 / totalVisits) * 100 },
{ endpoint: 'rotate-pdf', visits: 1523, percentage: (1523 / totalVisits) * 100 },
{ endpoint: 'ocr-pdf', visits: 1287, percentage: (1287 / totalVisits) * 100 },
{ endpoint: 'add-watermark', visits: 945, percentage: (945 / totalVisits) * 100 },
{ endpoint: 'extract-images', visits: 782, percentage: (782 / totalVisits) * 100 },
{ endpoint: 'add-password', visits: 621, percentage: (621 / totalVisits) * 100 },
{ endpoint: 'html-to-pdf', visits: 563, percentage: (563 / totalVisits) * 100 },
{ endpoint: 'remove-password', visits: 487, percentage: (487 / totalVisits) * 100 },
{ endpoint: 'pdf-to-pdfa', visits: 423, percentage: (423 / totalVisits) * 100 },
{ endpoint: 'extract-pdf-metadata', visits: 356, percentage: (356 / totalVisits) * 100 },
{ endpoint: 'add-page-numbers', visits: 298, percentage: (298 / totalVisits) * 100 },
{ endpoint: 'crop', visits: 245, percentage: (245 / totalVisits) * 100 },
{ endpoint: 'flatten', visits: 187, percentage: (187 / totalVisits) * 100 },
{ endpoint: 'sanitize-pdf', visits: 134, percentage: (134 / totalVisits) * 100 },
{ endpoint: 'auto-split-pdf', visits: 98, percentage: (98 / totalVisits) * 100 },
{ endpoint: 'scale-pages', visits: 76, percentage: (76 / totalVisits) * 100 },
{ endpoint: 'compare-pdfs', visits: 42, percentage: (42 / totalVisits) * 100 },
];
// Filter based on display mode
let filteredEndpoints = allEndpoints;
if (displayMode === 'top10') {
filteredEndpoints = allEndpoints.slice(0, 10);
} else if (displayMode === 'top20') {
filteredEndpoints = allEndpoints.slice(0, 20);
}
setData({
totalVisits: totalVisits,
totalEndpoints: filteredEndpoints.length,
endpoints: filteredEndpoints,
});
setLoading(false);
}
}, [displayMode, dataType, loginEnabled]);
const handleRefresh = () => {
if (!validateLoginEnabled()) {
return;
}
fetchData();
};
@@ -60,8 +112,11 @@ const AdminUsageSection: React.FC = () => {
}
};
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
// Early returns for loading/error states
if (loading) {
if (actualLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<Loader size="lg" />
@@ -85,16 +140,18 @@ const AdminUsageSection: React.FC = () => {
);
}
const chartData = data.endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
const chartData = data?.endpoints?.map((e) => ({ label: e.endpoint, value: e.visits })) || [];
const displayedVisits = data.endpoints.reduce((sum, e) => sum + e.visits, 0);
const displayedVisits = data?.endpoints?.reduce((sum, e) => sum + e.visits, 0) || 0;
const displayedPercentage = data.totalVisits > 0
? ((displayedVisits / data.totalVisits) * 100).toFixed(1)
const displayedPercentage = (data?.totalVisits || 0) > 0
? ((displayedVisits / (data?.totalVisits || 1)) * 100).toFixed(1)
: '0';
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
{/* Controls */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
@@ -103,6 +160,7 @@ const AdminUsageSection: React.FC = () => {
<SegmentedControl
value={displayMode}
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
disabled={!loginEnabled}
data={[
{
value: 'top10',
@@ -123,6 +181,7 @@ const AdminUsageSection: React.FC = () => {
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
onClick={handleRefresh}
loading={loading}
disabled={!loginEnabled}
>
{t('usage.controls.refresh', 'Refresh')}
</Button>
@@ -136,6 +195,7 @@ const AdminUsageSection: React.FC = () => {
<SegmentedControl
value={dataType}
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
disabled={!loginEnabled}
data={[
{
value: 'all',
@@ -13,10 +13,6 @@ import {
Group,
Modal,
Select,
Paper,
Checkbox,
Textarea,
SegmentedControl,
Tooltip,
CloseButton,
Avatar,
@@ -28,10 +24,14 @@ import { userManagementService, User } from '@app/services/userManagementService
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
@@ -40,8 +40,6 @@ export default function PeopleSection() {
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
@@ -53,31 +51,6 @@ export default function PeopleSection() {
totalUsers: number;
} | null>(null);
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
forceChange: false,
});
// Form state for email invite
const [emailInviteForm, setEmailInviteForm] = useState({
emails: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
});
// Form state for invite link
const [inviteLinkForm, setInviteLinkForm] = useState({
email: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
expiryHours: 72,
sendEmail: false,
});
// Form state for edit user modal
const [editForm, setEditForm] = useState({
role: 'ROLE_USER',
@@ -97,30 +70,103 @@ export default function PeopleSection() {
const fetchData = async () => {
try {
setLoading(true);
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
// Enrich users with session data
const enrichedUsers = adminData.users.map(user => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
}));
if (loginEnabled) {
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
setUsers(enrichedUsers);
setTeams(teamsData);
// Enrich users with session data
const enrichedUsers = adminData.users.map(user => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
}));
// Store license information
setLicenseInfo({
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots,
grandfatheredUserCount: adminData.grandfatheredUserCount,
licenseMaxUsers: adminData.licenseMaxUsers,
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
setUsers(enrichedUsers);
setTeams(teamsData);
// Store license information
setLicenseInfo({
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots,
grandfatheredUserCount: adminData.grandfatheredUserCount,
licenseMaxUsers: adminData.licenseMaxUsers,
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
} else {
// Provide example data when login is disabled
const exampleUsers: User[] = [
{
id: 1,
username: 'admin',
email: 'admin@example.com',
enabled: true,
roleName: 'ROLE_ADMIN',
rolesAsString: 'ROLE_ADMIN',
authenticationType: 'password',
isActive: true,
lastRequest: Date.now(),
team: { id: 1, name: 'Engineering' }
},
{
id: 2,
username: 'john.doe',
email: 'john.doe@example.com',
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
isActive: false,
lastRequest: Date.now() - 86400000,
team: { id: 1, name: 'Engineering' }
},
{
id: 3,
username: 'jane.smith',
email: 'jane.smith@example.com',
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'oauth',
isActive: true,
lastRequest: Date.now(),
team: { id: 2, name: 'Marketing' }
},
{
id: 4,
username: 'bob.wilson',
email: 'bob.wilson@example.com',
enabled: false,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
isActive: false,
lastRequest: Date.now() - 604800000,
team: undefined
}
];
const exampleTeams: Team[] = [
{ id: 1, name: 'Engineering', userCount: 3 },
{ id: 2, name: 'Marketing', userCount: 2 }
];
setUsers(exampleUsers);
setTeams(exampleTeams);
// Example license information
setLicenseInfo({
maxAllowedUsers: 10,
availableSlots: 6,
grandfatheredUserCount: 0,
licenseMaxUsers: 5,
premiumEnabled: true,
totalUsers: 4,
});
}
} catch (error) {
console.error('Failed to fetch people data:', error);
alert({ alertType: 'error', title: 'Failed to load people data' });
@@ -129,143 +175,6 @@ export default function PeopleSection() {
}
};
const handleInviteUser = async () => {
if (!inviteForm.username || !inviteForm.password) {
alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') });
return;
}
try {
setProcessing(true);
await userManagementService.createUser({
username: inviteForm.username,
password: inviteForm.password,
role: inviteForm.role,
teamId: inviteForm.teamId,
authType: 'password',
forceChange: inviteForm.forceChange,
});
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
setInviteModalOpened(false);
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined,
forceChange: false,
});
fetchData();
} catch (error: any) {
console.error('Failed to create user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.addMember.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleEmailInvite = async () => {
if (!emailInviteForm.emails.trim()) {
alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'At least one email address is required') });
return;
}
try {
setProcessing(true);
const response = await userManagementService.inviteUsers({
emails: emailInviteForm.emails,
role: emailInviteForm.role,
teamId: emailInviteForm.teamId,
});
if (response.successCount > 0) {
alert({
alertType: 'success',
title: t('workspace.people.emailInvite.success', `${response.successCount} user(s) invited successfully`)
});
if (response.failureCount > 0 && response.errors) {
alert({
alertType: 'warning',
title: t('workspace.people.emailInvite.partialSuccess', 'Some invites failed'),
body: response.errors
});
}
setInviteModalOpened(false);
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
teamId: undefined,
});
fetchData();
} else {
alert({
alertType: 'error',
title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'),
body: response.errors || response.error
});
}
} catch (error: any) {
console.error('Failed to invite users:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.emailInvite.error', 'Failed to send invites');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleGenerateInviteLink = async () => {
try {
setProcessing(true);
const response = await userManagementService.generateInviteLink({
email: inviteLinkForm.email,
role: inviteLinkForm.role,
teamId: inviteLinkForm.teamId,
expiryHours: inviteLinkForm.expiryHours,
sendEmail: inviteLinkForm.sendEmail,
});
// Construct full frontend URL
const frontendUrl = `${window.location.origin}/invite/${response.token}`;
setGeneratedInviteLink(frontendUrl);
if (response.emailSent) {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.successWithEmail', 'Invite link generated and email sent!')
});
} else if (inviteLinkForm.sendEmail && response.emailError) {
// Email was requested but failed
alert({
alertType: 'warning',
title: t('workspace.people.inviteLink.emailFailed', 'Invite link generated, but email failed'),
body: t('workspace.people.inviteLink.emailFailedDetails', 'Error: {0}. Please share the invite link manually.').replace('{0}', response.emailError)
});
} else {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.success', 'Invite link generated successfully!')
});
}
} catch (error: any) {
console.error('Failed to generate invite link:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.inviteLink.error', 'Failed to generate invite link');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleUpdateUserRole = async () => {
if (!selectedUser) return;
@@ -344,18 +253,6 @@ export default function PeopleSection() {
});
};
const closeInviteModal = () => {
setInviteModalOpened(false);
setGeneratedInviteLink(null);
setInviteLinkForm({
email: '',
role: 'ROLE_USER',
teamId: undefined,
expiryHours: 72,
sendEmail: false,
});
};
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase())
);
@@ -405,6 +302,7 @@ export default function PeopleSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.people.title')}
@@ -457,15 +355,15 @@ export default function PeopleSection() {
style={{ maxWidth: 300 }}
/>
<Tooltip
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
label={!loginEnabled ? 'Enable login mode first' : t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
disabled={loginEnabled && (!licenseInfo || licenseInfo.availableSlots > 0)}
position="bottom"
withArrow
>
<Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
onClick={() => setInviteModalOpened(true)}
disabled={licenseInfo ? licenseInfo.availableSlots === 0 : false}
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
>
{t('workspace.people.addMembers')}
</Button>
@@ -616,20 +514,21 @@ export default function PeopleSection() {
{/* Actions menu */}
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<ActionIcon variant="subtle" color="gray" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item onClick={() => openEditModal(user)}>{t('workspace.people.editRole')}</Menu.Item>
<Menu.Item onClick={() => openEditModal(user)} disabled={!loginEnabled}>{t('workspace.people.editRole')}</Menu.Item>
<Menu.Item
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
onClick={() => handleToggleEnabled(user)}
disabled={!loginEnabled}
>
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)}>
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)} disabled={!loginEnabled}>
{t('workspace.people.deleteUser')}
</Menu.Item>
</Menu.Dropdown>
@@ -642,277 +541,11 @@ export default function PeopleSection() {
</Table.Tbody>
</Table>
{/* Add Member Modal */}
<Modal
{/* Invite Members Modal (reusable) */}
<InviteMembersModal
opened={inviteModalOpened}
onClose={closeInviteModal}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<Box pos="relative">
<CloseButton
onClick={closeInviteModal}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.inviteMembers.label', 'Invite Members')}
</Text>
{inviteMode === 'email' && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
</Text>
)}
</Stack>
{/* License Warning/Info */}
{licenseInfo && (
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t('workspace.people.license.slotsAvailable', {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
})
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t('workspace.people.license.currentUsage', {
current: licenseInfo.totalUsers,
max: licenseInfo.maxAllowedUsers,
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
})}
</Text>
</Stack>
</Paper>
)}
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => {
setInviteMode(value as 'email' | 'direct' | 'link');
setGeneratedInviteLink(null);
}}
data={[
{
label: t('workspace.people.inviteMode.username', 'Username'),
value: 'direct',
},
{
label: t('workspace.people.inviteMode.link', 'Link'),
value: 'link',
},
{
label: t('workspace.people.inviteMode.email', 'Email'),
value: 'email',
disabled: !config?.enableEmailInvites,
},
]}
fullWidth
/>
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === 'link' && (
<>
<TextInput
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
placeholder={t('workspace.people.inviteLink.emailPlaceholder', 'user@example.com')}
value={inviteLinkForm.email}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<TextInput
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
checked={inviteLinkForm.sendEmail}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(generatedInviteLink);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement('textarea');
textArea.value = generatedInviteLink;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
}
}}
>
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
<Textarea
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', 'user1@example.com, user2@example.com')}
value={emailInviteForm.emails}
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
minRows={3}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={emailInviteForm.teamId?.toString()}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === 'direct' && (
<>
<TextInput
label={t('workspace.people.addMember.username')}
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
required
/>
<TextInput
label={t('workspace.people.addMember.password')}
type="password"
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteForm.role}
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteForm.teamId?.toString()}
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Checkbox
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
/>
</>
)}
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: inviteMode === 'link'
? t('workspace.people.inviteLink.submit', 'Generate Link')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</Box>
</Modal>
onClose={() => setInviteModalOpened(false)}
/>
{/* Edit User Modal */}
<Modal

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