Compare commits

...
Author SHA1 Message Date
Anthony Stirling e2e01366b8 Enable withCredentials for API client 2025-11-17 14:02:23 +00:00
Anthony Stirling 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
EthanHealy01 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 Brunton 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 Stirling 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
78 changed files with 2385 additions and 1285 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

+20 -3
View File
@@ -3177,6 +3177,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 +3655,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 +3684,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 +3760,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 +4799,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 +4859,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 +4941,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",
@@ -3967,7 +3967,7 @@
"account": "Account",
"config": "Config",
"adminSettings": "Admin Settings",
"allTools": "All Tools"
"allTools": "Tools"
},
"admin": {
"error": "Error",
@@ -68,11 +68,11 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
@@ -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',
@@ -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;
};
@@ -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;
@@ -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)
@@ -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 ||
@@ -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
@@ -22,9 +22,12 @@ import { teamService, Team } from '@app/services/teamService';
import { userManagementService, User } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import TeamDetailsSection from '@app/components/shared/config/configSections/TeamDetailsSection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
export default function TeamsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpened, setCreateModalOpened] = useState(false);
@@ -47,8 +50,18 @@ export default function TeamsSection() {
const fetchTeams = async () => {
try {
setLoading(true);
const teamsData = await teamService.getTeams();
setTeams(teamsData);
if (loginEnabled) {
const teamsData = await teamService.getTeams();
setTeams(teamsData);
} else {
// Provide example data when login is disabled
const exampleTeams: Team[] = [
{ id: 1, name: 'Engineering', userCount: 3 },
{ id: 2, name: 'Marketing', userCount: 2 },
{ id: 3, name: 'Internal', userCount: 1 },
];
setTeams(exampleTeams);
}
} catch (error) {
console.error('Failed to fetch teams:', error);
alert({ alertType: 'error', title: 'Failed to load teams' });
@@ -207,6 +220,7 @@ export default function TeamsSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.teams.title')}
@@ -218,7 +232,7 @@ export default function TeamsSection() {
{/* Header Actions */}
<Group justify="flex-end">
<Button leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />} onClick={() => setCreateModalOpened(true)}>
<Button leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />} onClick={() => setCreateModalOpened(true)} disabled={!loginEnabled}>
{t('workspace.teams.createNewTeam')}
</Button>
</Group>
@@ -257,8 +271,8 @@ export default function TeamsSection() {
teams.map((team) => (
<Table.Tr
key={team.id}
style={{ cursor: 'pointer' }}
onClick={() => setViewingTeamId(team.id)}
style={{ cursor: loginEnabled ? 'pointer' : 'default' }}
onClick={() => loginEnabled && setViewingTeamId(team.id)}
>
<Table.Td>
<Group gap="xs">
@@ -290,18 +304,18 @@ export default function TeamsSection() {
<Table.Td onClick={(e) => e.stopPropagation()}>
<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 leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />} onClick={() => setViewingTeamId(team.id)}>
<Menu.Item leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />} onClick={() => setViewingTeamId(team.id)} disabled={!loginEnabled}>
{t('workspace.teams.viewTeam', 'View Team')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />} onClick={() => openAddMemberModal(team)}>
<Menu.Item leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />} onClick={() => openAddMemberModal(team)} disabled={!loginEnabled}>
{t('workspace.teams.addMember')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />} onClick={() => openRenameModal(team)}>
<Menu.Item leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />} onClick={() => openRenameModal(team)} disabled={!loginEnabled}>
{t('workspace.teams.renameTeamLabel')}
</Menu.Item>
<Menu.Divider />
@@ -309,7 +323,7 @@ export default function TeamsSection() {
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteTeam(team)}
disabled={team.name === 'Internal'}
disabled={!loginEnabled || team.name === 'Internal'}
>
{t('workspace.teams.deleteTeamLabel')}
</Menu.Item>
@@ -53,7 +53,11 @@ const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, title, color = 'b
);
};
const AuditChartsSection: React.FC = () => {
interface AuditChartsSectionProps {
loginEnabled?: boolean;
}
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
@@ -74,8 +78,27 @@ const AuditChartsSection: React.FC = () => {
}
};
fetchChartsData();
}, [timePeriod]);
if (loginEnabled) {
fetchChartsData();
} else {
// Provide example charts data when login is disabled
setChartsData({
eventsByType: {
labels: ['LOGIN', 'LOGOUT', 'SETTINGS_CHANGE', 'FILE_UPLOAD', 'FILE_DOWNLOAD'],
values: [342, 289, 145, 678, 523],
},
eventsByUser: {
labels: ['admin', 'user1', 'user2', 'user3', 'user4'],
values: [456, 321, 287, 198, 165],
},
eventsOverTime: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
values: [123, 145, 167, 189, 201, 87, 65],
},
});
setLoading(false);
}
}, [timePeriod, loginEnabled]);
if (loading) {
return (
@@ -123,7 +146,11 @@ const AuditChartsSection: React.FC = () => {
</Text>
<SegmentedControl
value={timePeriod}
onChange={(value) => setTimePeriod(value as 'day' | 'week' | 'month')}
onChange={(value) => {
if (!loginEnabled) return;
setTimePeriod(value as 'day' | 'week' | 'month');
}}
disabled={!loginEnabled}
data={[
{ label: t('audit.charts.day', 'Day'), value: 'day' },
{ label: t('audit.charts.week', 'Week'), value: 'week' },
@@ -18,7 +18,11 @@ import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
const AuditEventsTable: React.FC = () => {
interface AuditEventsTableProps {
loginEnabled?: boolean;
}
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [events, setEvents] = useState<AuditEvent[]>([]);
const [totalPages, setTotalPages] = useState(0);
@@ -51,8 +55,57 @@ const AuditEventsTable: React.FC = () => {
}
};
fetchEvents();
}, [filters, currentPage]);
if (loginEnabled) {
fetchEvents();
} else {
// Provide example audit events when login is disabled
const now = new Date();
setEvents([
{
id: '1',
timestamp: new Date(now.getTime() - 1000 * 60 * 15).toISOString(),
eventType: 'LOGIN',
username: 'admin',
ipAddress: '192.168.1.100',
details: { message: 'User logged in successfully' },
},
{
id: '2',
timestamp: new Date(now.getTime() - 1000 * 60 * 30).toISOString(),
eventType: 'FILE_UPLOAD',
username: 'user1',
ipAddress: '192.168.1.101',
details: { message: 'Uploaded document.pdf' },
},
{
id: '3',
timestamp: new Date(now.getTime() - 1000 * 60 * 45).toISOString(),
eventType: 'SETTINGS_CHANGE',
username: 'admin',
ipAddress: '192.168.1.100',
details: { message: 'Modified system settings' },
},
{
id: '4',
timestamp: new Date(now.getTime() - 1000 * 60 * 60).toISOString(),
eventType: 'FILE_DOWNLOAD',
username: 'user2',
ipAddress: '192.168.1.102',
details: { message: 'Downloaded report.pdf' },
},
{
id: '5',
timestamp: new Date(now.getTime() - 1000 * 60 * 90).toISOString(),
eventType: 'LOGOUT',
username: 'user1',
ipAddress: '192.168.1.101',
details: { message: 'User logged out' },
},
]);
setTotalPages(1);
setLoading(false);
}
}, [filters, currentPage, loginEnabled]);
// Wrap filter handlers to reset pagination
const handleFilterChangeWithReset = (key: keyof typeof filters, value: any) => {
@@ -83,6 +136,7 @@ const AuditEventsTable: React.FC = () => {
users={users}
onFilterChange={handleFilterChangeWithReset}
onClearFilters={handleClearFiltersWithReset}
disabled={!loginEnabled}
/>
{/* Table */}
@@ -153,6 +207,7 @@ const AuditEventsTable: React.FC = () => {
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
>
{t('audit.events.viewDetails', 'View Details')}
</Button>
@@ -13,7 +13,11 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
const AuditExportSection: React.FC = () => {
interface AuditExportSectionProps {
loginEnabled?: boolean;
}
const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
const [exporting, setExporting] = useState(false);
@@ -22,6 +26,8 @@ const AuditExportSection: React.FC = () => {
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters();
const handleExport = async () => {
if (!loginEnabled) return;
try {
setExporting(true);
@@ -65,7 +71,11 @@ const AuditExportSection: React.FC = () => {
</Text>
<SegmentedControl
value={exportFormat}
onChange={(value) => setExportFormat(value as 'csv' | 'json')}
onChange={(value) => {
if (!loginEnabled) return;
setExportFormat(value as 'csv' | 'json');
}}
disabled={!loginEnabled}
data={[
{ label: 'CSV', value: 'csv' },
{ label: 'JSON', value: 'json' },
@@ -84,6 +94,7 @@ const AuditExportSection: React.FC = () => {
users={users}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
disabled={!loginEnabled}
/>
</div>
@@ -93,7 +104,7 @@ const AuditExportSection: React.FC = () => {
leftSection={<LocalIcon icon="download" width="1rem" height="1rem" />}
onClick={handleExport}
loading={exporting}
disabled={exporting}
disabled={!loginEnabled || exporting}
>
{t('audit.export.exportButton', 'Export Data')}
</Button>
@@ -11,6 +11,7 @@ interface AuditFiltersFormProps {
users: string[];
onFilterChange: (key: keyof AuditFilters, value: any) => void;
onClearFilters: () => void;
disabled?: boolean;
}
/**
@@ -22,6 +23,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
users,
onFilterChange,
onClearFilters,
disabled = false,
}) => {
const { t } = useTranslation();
@@ -33,6 +35,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
value={filters.eventType}
onChange={(value) => onFilterChange('eventType', value || undefined)}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
@@ -43,6 +46,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
onChange={(value) => onFilterChange('username', value || undefined)}
clearable
searchable
disabled={disabled}
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
@@ -53,6 +57,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
onFilterChange('startDate', value ?? undefined)
}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
@@ -63,10 +68,11 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
onFilterChange('endDate', value ?? undefined)
}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button variant="outline" onClick={onClearFilters}>
<Button variant="outline" onClick={onClearFilters} disabled={disabled}>
{t('audit.events.clearFilters', 'Clear')}
</Button>
</Group>
+3 -9
View File
@@ -3,7 +3,7 @@ import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAppConfig } from '@app/contexts/AppConfigContext'
import HomePage from '@app/pages/HomePage'
import Login from '@app/routes/Login'
// Login component is used via routing, not directly imported
import FirstLoginModal from '@app/components/shared/FirstLoginModal'
import { accountService } from '@app/services/accountService'
@@ -95,13 +95,7 @@ export default function Landing() {
);
}
// If we're at home route ("/"), show login directly (marketing/landing page)
// Otherwise navigate to login (fixes URL mismatch for tool routes)
const isHome = location.pathname === '/' || location.pathname === '';
if (isHome) {
return <Login />;
}
// For non-home routes without auth, navigate to login (preserves from location)
// No session - redirect to login page
// This ensures the URL always shows /login when not authenticated
return <Navigate to="/login" replace state={{ from: location }} />;
}
+80 -45
View File
@@ -10,7 +10,7 @@ import AuthLayout from '@app/routes/authShared/AuthLayout';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
import OAuthButtons from '@app/routes/login/OAuthButtons';
import OAuthButtons, { DEBUG_SHOW_ALL_PROVIDERS, oauthProviderConfig } from '@app/routes/login/OAuthButtons';
import DividerWithText from '@app/components/shared/DividerWithText';
import LoggedInState from '@app/routes/login/LoggedInState';
import { BASE_PATH } from '@app/constants/app';
@@ -26,8 +26,55 @@ export default function Login() {
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
// Handle query params (email prefill and success messages)
// Fetch enabled SSO providers and login config from backend
useEffect(() => {
const fetchProviders = async () => {
try {
const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`);
if (response.ok) {
const data = await response.json();
// Check if login is disabled - if so, redirect to home
if (data.enableLogin === false) {
console.debug('[Login] Login disabled, redirecting to home');
navigate('/');
return;
}
setEnableLogin(data.enableLogin ?? true);
// Extract provider IDs from the providerList map
// The keys are like "/oauth2/authorization/google" - extract the last part
const providerIds = Object.keys(data.providerList || {})
.map(key => key.split('/').pop())
.filter((id): id is string => id !== undefined);
setEnabledProviders(providerIds);
}
} catch (err) {
console.error('[Login] Failed to fetch enabled providers:', err);
}
};
fetchProviders();
}, [navigate]);
// Update hasSSOProviders and showEmailForm when enabledProviders changes
useEffect(() => {
// In debug mode, check if any providers exist in the config
const hasProviders = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig).length > 0
: enabledProviders.length > 0;
setHasSSOProviders(hasProviders);
// If no SSO providers, show email form by default
if (!hasProviders) {
setShowEmailForm(true);
}
}, [enabledProviders]);
// Handle query params (email prefill, success messages, and session expiry)
useEffect(() => {
try {
const emailFromQuery = searchParams.get('email');
@@ -35,6 +82,12 @@ export default function Login() {
setEmail(emailFromQuery);
}
// Check if session expired (401 redirect)
const expired = searchParams.get('expired');
if (expired === 'true') {
setError(t('login.sessionExpired', 'Your session has expired. Please sign in again.'));
}
const messageType = searchParams.get('messageType')
if (messageType) {
switch (messageType) {
@@ -71,7 +124,7 @@ export default function Login() {
return <LoggedInState />;
}
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => {
try {
setIsSigningIn(true);
setError(null);
@@ -129,9 +182,10 @@ export default function Login() {
}
};
const handleForgotPassword = () => {
navigate('/auth/reset');
};
// Forgot password handler (currently unused, reserved for future implementation)
// const handleForgotPassword = () => {
// navigate('/auth/reset');
// };
return (
<AuthLayout>
@@ -160,25 +214,31 @@ export default function Login() {
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={enabledProviders}
/>
{/* Divider between OAuth and Email */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
{/* Divider between OAuth and Email - only show if SSO is available */}
{hasSSOProviders && (
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
)}
{/* Sign in with email button (primary color to match signup CTA) */}
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm(true)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Login with email')}
</button>
</div>
{/* Sign in with email button - only show if SSO providers exist */}
{hasSSOProviders && !showEmailForm && (
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm(true)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Login with email')}
</button>
</div>
)}
{/* Email form - show by default if no SSO, or when button clicked */}
{showEmailForm && (
<div style={{ marginTop: '1rem' }}>
<div style={{ marginTop: hasSSOProviders ? '1rem' : '0' }}>
<EmailPasswordForm
email={email}
password={password}
@@ -191,31 +251,6 @@ export default function Login() {
</div>
)}
{showEmailForm && (
<div className="auth-section-sm">
<button
type="button"
onClick={handleForgotPassword}
className="auth-link-black"
>
{t('login.forgotPassword', 'Forgot your password?')}
</button>
</div>
)}
{/* Divider then signup link */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
<div style={{ textAlign: 'center', margin: '0.5rem 0 0.25rem' }}>
<button
type="button"
onClick={() => navigate('/signup')}
className="auth-link-black"
>
{t('signup.signUp', 'Sign up')}
</button>
</div>
</AuthLayout>
);
}
@@ -239,7 +239,7 @@
}
.login-logo-text {
height: 1.5rem; /* 24px */
height: 2rem; /* 32px - increased from 24px */
}
.login-title {
@@ -2,11 +2,13 @@ import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@app/auth/UseSession';
import { useTranslation } from 'react-i18next';
import { useLogoPath } from '@app/hooks/useLogoPath';
export default function LoggedInState() {
const navigate = useNavigate();
const { user } = useAuth();
const { t } = useTranslation();
const logoPath = useLogoPath();
useEffect(() => {
const timer = setTimeout(() => {
@@ -34,7 +36,13 @@ export default function LoggedInState() {
padding: '32px'
}}>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'center' }}>
<img
src={logoPath}
alt="Stirling PDF Logo"
style={{ width: '64px', height: '64px', objectFit: 'contain' }}
/>
</div>
<h1 style={{ fontSize: '24px', fontWeight: 'bold', color: '#059669', marginBottom: '8px' }}>
{t('login.youAreLoggedIn')}
</h1>
@@ -10,7 +10,6 @@ export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
return (
<div className="login-header">
<div className="login-header-logos">
<img src={`${BASE_PATH}/logo192.png`} alt="Logo" className="login-logo-icon" />
<img src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} alt="Stirling PDF" className="login-logo-text" />
</div>
<h1 className="login-title">{title}</h1>
@@ -1,30 +1,54 @@
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '@app/constants/app';
// OAuth provider configuration
const oauthProviders = [
{ id: 'google', label: 'Google', file: 'google.svg', isDisabled: false },
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
{ id: 'apple', label: 'Apple', file: 'apple.svg', isDisabled: true },
{ id: 'azure', label: 'Microsoft', file: 'microsoft.svg', isDisabled: true }
];
// Debug flag to show all providers for UI testing
// Set to true to see all SSO options regardless of backend configuration
export const DEBUG_SHOW_ALL_PROVIDERS = false;
// OAuth provider configuration - maps provider ID to display info
export const oauthProviderConfig = {
google: { label: 'Google', file: 'google.svg' },
github: { label: 'GitHub', file: 'github.svg' },
apple: { label: 'Apple', file: 'apple.svg' },
azure: { label: 'Microsoft', file: 'microsoft.svg' },
// microsoft and azure are the same, keycloak and oidc need their own icons
// These are commented out from debug view since they need proper icons or backend doesn't use them
// keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
// oidc: { label: 'OIDC', file: 'oidc.svg' }
};
interface OAuthButtonsProps {
onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure') => void
onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => void
isSubmitting: boolean
layout?: 'vertical' | 'grid' | 'icons'
enabledProviders?: string[] // List of enabled provider IDs from backend
}
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical' }: OAuthButtonsProps) {
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) {
const { t } = useTranslation();
// Filter out disabled providers - don't show them at all
const enabledProviders = oauthProviders.filter(p => !p.isDisabled);
// Debug mode: show all providers for UI testing
const providersToShow = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig)
: enabledProviders;
// Filter to only show enabled providers from backend
const providers = providersToShow
.filter(id => id in oauthProviderConfig)
.map(id => ({
id,
...oauthProviderConfig[id as keyof typeof oauthProviderConfig]
}));
// If no providers are enabled, don't render anything
if (providers.length === 0) {
return null;
}
if (layout === 'icons') {
return (
<div className="oauth-container-icons">
{enabledProviders.map((p) => (
{providers.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button
onClick={() => onProviderClick(p.id as any)}
@@ -43,7 +67,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
if (layout === 'grid') {
return (
<div className="oauth-container-grid">
{enabledProviders.map((p) => (
{providers.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button
onClick={() => onProviderClick(p.id as any)}
@@ -61,7 +85,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
return (
<div className="oauth-container-vertical">
{enabledProviders.map((p) => (
{providers.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as any)}
@@ -9,17 +9,38 @@ function getJwtTokenFromStorage(): string | null {
}
}
function getXsrfToken(): string | null {
try {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return decodeURIComponent(value);
}
}
return null;
} catch (error) {
console.error('[API Client] Failed to read XSRF token from cookies:', error);
return null;
}
}
export function setupApiInterceptors(client: AxiosInstance): void {
// Install request interceptor to add JWT token
client.interceptors.request.use(
(config) => {
const jwtToken = getJwtTokenFromStorage();
const xsrfToken = getXsrfToken();
if (jwtToken && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${jwtToken}`;
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
}
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {
config.headers['X-XSRF-TOKEN'] = xsrfToken;
}
return config;
},
(error) => {
@@ -69,7 +69,7 @@ export interface InviteUsersResponse {
}
export interface InviteLinkRequest {
email: string;
email?: string;
role: string;
teamId?: number;
expiryHours?: number;