mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e01366b8 | ||
|
|
4b43693e29 | ||
|
|
5c9e590856 | ||
|
|
be78c72887 | ||
|
|
1117ce6164 |
@@ -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
|
||||
@@ -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
-1
@@ -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:
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
+41
-49
@@ -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
|
||||
|
||||
+2
-4
@@ -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;
|
||||
|
||||
+2
-1
@@ -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
@@ -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'
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+79
-15
@@ -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>
|
||||
+27
-9
@@ -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>
|
||||
) : (
|
||||
+61
-29
@@ -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>
|
||||
+40
-14
@@ -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>
|
||||
+28
-6
@@ -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>
|
||||
+35
-12
@@ -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>
|
||||
+101
-23
@@ -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>
|
||||
+20
-4
@@ -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>
|
||||
+22
-5
@@ -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>
|
||||
+39
-10
@@ -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>
|
||||
+89
-52
@@ -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 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 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 .internal .corp .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>
|
||||
+67
-7
@@ -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',
|
||||
+110
-477
@@ -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>
|
||||
|
||||
+31
-4
@@ -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' },
|
||||
+58
-3
@@ -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>
|
||||
+14
-3
@@ -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>
|
||||
+7
-1
@@ -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,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 }} />;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ export interface InviteUsersResponse {
|
||||
}
|
||||
|
||||
export interface InviteLinkRequest {
|
||||
email: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
expiryHours?: number;
|
||||
Reference in New Issue
Block a user