Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a09ff39eb5 | ||
|
|
731743b618 | ||
|
|
04c4aec0d8 | ||
|
|
f63df148ad | ||
|
|
6f0be94bd6 | ||
|
|
e47ed13be8 | ||
|
|
a62c8b54cf | ||
|
|
1e34038b1e |
@@ -90,12 +90,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
variant:
|
||||
- name: "default"
|
||||
disable_security: true
|
||||
build_frontend: true
|
||||
file_suffix: ""
|
||||
- name: "with-login"
|
||||
disable_security: false
|
||||
build_frontend: true
|
||||
file_suffix: "-with-login"
|
||||
- name: "server-only"
|
||||
disable_security: true
|
||||
build_frontend: false
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
@@ -114,10 +121,18 @@ jobs:
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build JAR
|
||||
run: ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
run: ./gradlew clean build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Rename JAR
|
||||
@@ -126,12 +141,12 @@ jobs:
|
||||
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
|
||||
ls -la app/core/build/libs/
|
||||
mkdir -p ./jar-dist
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: jar${{ matrix.file_suffix }}
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
retention-days: 1
|
||||
|
||||
@@ -524,7 +539,7 @@ jobs:
|
||||
pattern: Stirling-PDF-*
|
||||
path: ./artifacts/tauri
|
||||
|
||||
- name: Download JAR artifact (no login)
|
||||
- name: Download JAR artifact (default)
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar
|
||||
@@ -536,6 +551,12 @@ jobs:
|
||||
name: jar-with-login
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (server only)
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar-server
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./artifacts
|
||||
|
||||
|
||||
+16
-2
@@ -17,8 +17,8 @@ local.properties
|
||||
version.properties
|
||||
|
||||
#### Stirling-PDF Files ###
|
||||
pipeline/watchedFolders/
|
||||
pipeline/finishedFolders/
|
||||
pipeline/
|
||||
!pipeline/.gitkeep
|
||||
customFiles/
|
||||
configs/
|
||||
watchedFolders/
|
||||
@@ -31,6 +31,20 @@ exampleYmlFiles/stirling/
|
||||
/testing/file_snapshots
|
||||
SwaggerDoc.json
|
||||
|
||||
# Frontend build artifacts copied to backend static resources
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
app/core/src/main/resources/static/index.html
|
||||
app/core/src/main/resources/static/locales/
|
||||
app/core/src/main/resources/static/Login/
|
||||
app/core/src/main/resources/static/classic-logo/
|
||||
app/core/src/main/resources/static/modern-logo/
|
||||
app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
|
||||
# Gradle
|
||||
.gradle
|
||||
.gradle-home
|
||||
|
||||
@@ -6,6 +6,10 @@ Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "app/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -7,23 +7,103 @@ public class RequestUriUtils {
|
||||
}
|
||||
|
||||
public static boolean isStaticResource(String contextPath, String requestURI) {
|
||||
return requestURI.startsWith(contextPath + "/css/")
|
||||
|| requestURI.startsWith(contextPath + "/fonts/")
|
||||
|| requestURI.startsWith(contextPath + "/js/")
|
||||
|| requestURI.endsWith(contextPath + "robots.txt")
|
||||
|| requestURI.startsWith(contextPath + "/images/")
|
||||
|| requestURI.startsWith(contextPath + "/public/")
|
||||
|| requestURI.startsWith(contextPath + "/pdfjs/")
|
||||
|| requestURI.startsWith(contextPath + "/pdfjs-legacy/")
|
||||
|| requestURI.startsWith(contextPath + "/login")
|
||||
|| requestURI.startsWith(contextPath + "/error")
|
||||
|| requestURI.startsWith(contextPath + "/favicon")
|
||||
|| requestURI.endsWith(".svg")
|
||||
|| requestURI.endsWith(".png")
|
||||
|| requestURI.endsWith(".ico")
|
||||
|| requestURI.endsWith(".txt")
|
||||
|| requestURI.endsWith(".webmanifest")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/info/status");
|
||||
if (requestURI == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalizedUri = stripContextPath(contextPath, requestURI);
|
||||
|
||||
// API routes are never static except for the public status endpoint
|
||||
if (normalizedUri.startsWith("/api/")) {
|
||||
return normalizedUri.startsWith("/api/v1/info/status");
|
||||
}
|
||||
|
||||
// Well-known static asset directories (backend + React build artifacts)
|
||||
if (normalizedUri.startsWith("/css/")
|
||||
|| normalizedUri.startsWith("/fonts/")
|
||||
|| normalizedUri.startsWith("/js/")
|
||||
|| normalizedUri.startsWith("/images/")
|
||||
|| normalizedUri.startsWith("/public/")
|
||||
|| normalizedUri.startsWith("/pdfjs/")
|
||||
|| normalizedUri.startsWith("/pdfjs-legacy/")
|
||||
|| normalizedUri.startsWith("/assets/")
|
||||
|| normalizedUri.startsWith("/locales/")
|
||||
|| normalizedUri.startsWith("/Login/")
|
||||
|| normalizedUri.startsWith("/samples/")
|
||||
|| normalizedUri.startsWith("/classic-logo/")
|
||||
|| normalizedUri.startsWith("/modern-logo/")
|
||||
|| normalizedUri.startsWith("/og_images/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Specific static files bundled with the frontend
|
||||
if (normalizedUri.equals("/robots.txt")
|
||||
|| normalizedUri.equals("/favicon.ico")
|
||||
|| normalizedUri.equals("/site.webmanifest")
|
||||
|| normalizedUri.equals("/manifest-classic.json")
|
||||
|| normalizedUri.equals("/index.html")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Login/error pages remain public
|
||||
if (normalizedUri.startsWith("/login") || normalizedUri.startsWith("/error")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
|| normalizedUri.endsWith(".ico")
|
||||
|| normalizedUri.endsWith(".txt")
|
||||
|| normalizedUri.endsWith(".webmanifest")
|
||||
|| normalizedUri.endsWith(".js")
|
||||
|| normalizedUri.endsWith(".css")
|
||||
|| normalizedUri.endsWith(".mjs")
|
||||
|| normalizedUri.endsWith(".html")
|
||||
|| normalizedUri.endsWith(".toml");
|
||||
}
|
||||
|
||||
public static boolean isFrontendRoute(String contextPath, String requestURI) {
|
||||
if (requestURI == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalizedUri = stripContextPath(contextPath, requestURI);
|
||||
|
||||
// APIs are never treated as frontend routes
|
||||
if (normalizedUri.startsWith("/api/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blocklist of backend/non-frontend paths that should still go through filters
|
||||
String[] backendOnlyPrefixes = {
|
||||
"/register",
|
||||
"/invite",
|
||||
"/pipeline",
|
||||
"/pdfjs",
|
||||
"/pdfjs-legacy",
|
||||
"/fonts",
|
||||
"/images",
|
||||
"/files",
|
||||
"/css",
|
||||
"/js",
|
||||
"/swagger",
|
||||
"/v1/api-docs",
|
||||
"/actuator"
|
||||
};
|
||||
|
||||
for (String prefix : backendOnlyPrefixes) {
|
||||
if (normalizedUri.equals(prefix) || normalizedUri.startsWith(prefix + "/")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedUri.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow root and any extensionless path (React Router will handle these)
|
||||
return !normalizedUri.contains(".");
|
||||
}
|
||||
|
||||
public static boolean isTrackableResource(String requestURI) {
|
||||
@@ -43,6 +123,7 @@ public class RequestUriUtils {
|
||||
|| requestURI.endsWith(".svg")
|
||||
|| requestURI.endsWith("popularity.txt")
|
||||
|| requestURI.endsWith(".js")
|
||||
|| requestURI.endsWith(".toml")
|
||||
|| requestURI.contains("swagger")
|
||||
|| requestURI.startsWith("/api/v1/info")
|
||||
|| requestURI.startsWith("/site.webmanifest")
|
||||
@@ -83,4 +164,11 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|| trimmedUri.contains("/v1/api-docs");
|
||||
}
|
||||
|
||||
private static String stripContextPath(String contextPath, String requestURI) {
|
||||
if (contextPath != null && !contextPath.isBlank() && requestURI.startsWith(contextPath)) {
|
||||
return requestURI.substring(contextPath.length());
|
||||
}
|
||||
return requestURI;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,26 @@ public class RequestUriUtilsTest {
|
||||
"API products should not be static");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute() {
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
|
||||
"React routes without extensions should be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/api/v1/users"),
|
||||
"API routes should not be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/register"),
|
||||
"Register should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/pipeline/jobs"),
|
||||
"Pipeline should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/files/download"),
|
||||
"Files path should not be treated as a frontend route");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResourceWithContextPath() {
|
||||
String contextPath = "/myapp";
|
||||
@@ -83,6 +103,7 @@ public class RequestUriUtilsTest {
|
||||
"/favicon.ico",
|
||||
"/icon.svg",
|
||||
"/image.png",
|
||||
"/locales/en/translation.toml",
|
||||
"/site.webmanifest",
|
||||
"/app/logo.svg",
|
||||
"/downloads/document.png",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
apply plugin: 'org.springframework.boot'
|
||||
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
|
||||
repositories {
|
||||
maven { url = 'https://build.shibboleth.net/maven/releases' }
|
||||
maven { url = 'https://maven.pkg.github.com/jcefmaven/jcefmaven' }
|
||||
@@ -15,6 +17,7 @@ configurations {
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
@@ -25,12 +28,14 @@ spotless {
|
||||
}
|
||||
yaml {
|
||||
target '**/*.yml', '**/*.yaml'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
format 'gradle', {
|
||||
target '**/gradle/*.gradle', '**/*.gradle'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
@@ -157,5 +162,125 @@ springBoot {
|
||||
mainClass = 'stirling.software.SPDF.SPDFApplication'
|
||||
}
|
||||
|
||||
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
|
||||
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
|
||||
def frontendDir = file('../../frontend')
|
||||
def frontendDistDir = file('../../frontend/dist')
|
||||
def resourcesStaticDir = file('src/main/resources/static')
|
||||
def generatedFrontendPaths = [
|
||||
'assets',
|
||||
'index.html',
|
||||
'locales',
|
||||
'Login',
|
||||
'classic-logo',
|
||||
'modern-logo',
|
||||
'og_images',
|
||||
'samples',
|
||||
'manifest-classic.json'
|
||||
]
|
||||
|
||||
tasks.register('npmInstall', Exec) {
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Install frontend dependencies'
|
||||
workingDir frontendDir
|
||||
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'ci', '--prefer-offline'] : ['npm', 'ci', '--prefer-offline']
|
||||
inputs.file(new File(frontendDir, 'package.json'))
|
||||
inputs.file(new File(frontendDir, 'package-lock.json'))
|
||||
outputs.dir(new File(frontendDir, 'node_modules'))
|
||||
|
||||
// Show live output
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
|
||||
// Skip if node_modules exists and is up-to-date
|
||||
onlyIf {
|
||||
def nodeModules = new File(frontendDir, 'node_modules')
|
||||
if (!nodeModules.exists()) {
|
||||
println "node_modules not found, will install..."
|
||||
return true
|
||||
}
|
||||
def packageJson = new File(frontendDir, 'package.json')
|
||||
def packageLock = new File(frontendDir, 'package-lock.json')
|
||||
def isOutdated = nodeModules.lastModified() < packageJson.lastModified() ||
|
||||
nodeModules.lastModified() < packageLock.lastModified()
|
||||
if (isOutdated) {
|
||||
println "package.json or package-lock.json changed, will reinstall..."
|
||||
} else {
|
||||
println "node_modules is up-to-date, skipping npm install"
|
||||
}
|
||||
return isOutdated
|
||||
}
|
||||
|
||||
doFirst {
|
||||
println "Installing npm dependencies in ${frontendDir}..."
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('npmBuild', Exec) {
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Build frontend application'
|
||||
workingDir frontendDir
|
||||
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'run', 'build'] : ['npm', 'run', 'build']
|
||||
dependsOn npmInstall
|
||||
inputs.dir(new File(frontendDir, 'src'))
|
||||
inputs.file(new File(frontendDir, 'package.json'))
|
||||
outputs.dir(frontendDistDir)
|
||||
|
||||
// Show live output
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
|
||||
// Override VITE_API_BASE_URL to use relative paths for production builds
|
||||
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
|
||||
environment 'VITE_API_BASE_URL', '/'
|
||||
|
||||
doFirst {
|
||||
println "Building frontend application for production (VITE_API_BASE_URL=/)"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('copyFrontendAssets', Copy) {
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Copy frontend build to static resources'
|
||||
dependsOn npmBuild
|
||||
from(frontendDistDir) {
|
||||
// Exclude files that conflict with backend static resources
|
||||
exclude 'robots.txt' // Backend already has this
|
||||
exclude 'favicon.ico' // Backend already has this
|
||||
}
|
||||
into resourcesStaticDir
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
|
||||
doFirst {
|
||||
println "Copying frontend build from ${frontendDistDir} to ${resourcesStaticDir}..."
|
||||
println "Backend static resources will be preserved"
|
||||
}
|
||||
doLast {
|
||||
println "Frontend assets copied successfully!"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('cleanFrontendAssets', Delete) {
|
||||
group = 'frontend'
|
||||
description = 'Remove previously generated frontend assets from static resources'
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
}
|
||||
|
||||
// Ensure copyFrontendAssets runs after spotless tasks
|
||||
tasks.named('copyFrontendAssets').configure {
|
||||
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
|
||||
}
|
||||
|
||||
if (buildWithFrontend) {
|
||||
println "Frontend build enabled - JAR will include React frontend"
|
||||
processResources.dependsOn copyFrontendAssets
|
||||
} else {
|
||||
println "Frontend build disabled - JAR will be backend-only"
|
||||
// When not building the UI, ensure any stale frontend assets are removed
|
||||
processResources.dependsOn cleanFrontendAssets
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
bootJar.dependsOn ':proprietary:jar'
|
||||
|
||||
+4
-3
@@ -1,16 +1,17 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
|
||||
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
|
||||
public String forwardRootPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
@GetMapping("/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public String forwardNestedPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ server.servlet.session.timeout:30m
|
||||
springdoc.api-docs.path=/v1/api-docs
|
||||
# Set the URL of the OpenAPI JSON for the Swagger UI
|
||||
springdoc.swagger-ui.url=/v1/api-docs
|
||||
springdoc.swagger-ui.path=/index.html
|
||||
springdoc.swagger-ui.path=/swagger-ui.html
|
||||
# Force OpenAPI 3.0 specification version
|
||||
springdoc.api-docs.version=OPENAPI_3_0
|
||||
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
|
||||
|
||||
@@ -76,4 +76,9 @@
|
||||
.cm__btns{
|
||||
padding-top: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
+65
-38
@@ -28,6 +28,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
@@ -110,7 +111,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
// If we still don't have any authentication, check if it's a public endpoint. If not, deny
|
||||
// the request
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
String method = request.getMethod();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
// Allow public auth endpoints to pass through without authentication
|
||||
@@ -119,18 +119,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
|
||||
response.sendRedirect(contextPath + "/login"); // redirect to the login page
|
||||
} else {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
Authentication required. Please provide a X-API-KEY in request header.
|
||||
This is found in Settings -> Account Settings -> API Key
|
||||
Alternatively you can disable authentication if this is unexpected.
|
||||
""");
|
||||
}
|
||||
// For API requests, return 401 with JSON response (no redirects)
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Authentication required. Please provide valid credentials or X-API-KEY header.",
|
||||
"status": 401
|
||||
}
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,8 +179,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
// Block user registration if not allowed by configuration
|
||||
if (blockRegistration && !isUserExists) {
|
||||
log.warn("Blocked registration for OAuth2/SAML user: {}", username);
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?oAuth2AdminBlockedUser=true");
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
{
|
||||
"error": "Forbidden",
|
||||
"message": "User registration is blocked by administrator",
|
||||
"status": 403
|
||||
}
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,13 +204,35 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to logout if credentials are invalid
|
||||
// Return 401 if credentials are invalid (no redirects)
|
||||
if (!isUserExists && notSsoLogin) {
|
||||
response.sendRedirect(request.getContextPath() + "/logout?badCredentials=true");
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Invalid credentials",
|
||||
"status": 401
|
||||
}
|
||||
""");
|
||||
return;
|
||||
}
|
||||
if (isUserDisabled) {
|
||||
response.sendRedirect(request.getContextPath() + "/logout?userIsDisabled=true");
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
{
|
||||
"error": "Forbidden",
|
||||
"message": "User account is disabled",
|
||||
"status": 403
|
||||
}
|
||||
""");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -250,33 +282,28 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
String[] permitAllPatterns = {
|
||||
contextPath + "/login",
|
||||
contextPath + "/register",
|
||||
contextPath + "/invite",
|
||||
contextPath + "/error",
|
||||
contextPath + "/images/",
|
||||
contextPath + "/public/",
|
||||
contextPath + "/css/",
|
||||
contextPath + "/fonts/",
|
||||
contextPath + "/js/",
|
||||
contextPath + "/pdfjs/",
|
||||
contextPath + "/pdfjs-legacy/",
|
||||
|
||||
// Allow unauthenticated access to static resources and SPA routes (GET/HEAD only)
|
||||
if ("GET".equalsIgnoreCase(request.getMethod())
|
||||
|| "HEAD".equalsIgnoreCase(request.getMethod())) {
|
||||
if (RequestUriUtils.isStaticResource(contextPath, uri)
|
||||
|| RequestUriUtils.isFrontendRoute(contextPath, uri)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For API routes, only skip filter for these public endpoints
|
||||
String[] publicApiPatterns = {
|
||||
contextPath + "/api/v1/info/status",
|
||||
contextPath + "/api/v1/auth/login",
|
||||
contextPath + "/api/v1/auth/refresh",
|
||||
contextPath + "/api/v1/auth/me",
|
||||
contextPath + "/api/v1/invite/validate",
|
||||
contextPath + "/api/v1/invite/accept",
|
||||
contextPath + "/site.webmanifest"
|
||||
contextPath + "/api/v1/invite/accept"
|
||||
};
|
||||
|
||||
for (String pattern : permitAllPatterns) {
|
||||
if (uri.startsWith(pattern)
|
||||
|| uri.endsWith(".svg")
|
||||
|| uri.endsWith(".mjs")
|
||||
|| uri.endsWith(".png")
|
||||
|| uri.endsWith(".ico")) {
|
||||
for (String pattern : publicApiPatterns) {
|
||||
if (uri.startsWith(pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.0.0'
|
||||
version = '2.0.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -10,9 +10,11 @@ COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend .
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false npm run build
|
||||
# Override VITE_API_BASE_URL to use relative paths for production
|
||||
# This ensures frontend works with nginx proxy setup
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false VITE_API_BASE_URL=/ npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
# Stage 2: Build Backend (server-only JAR - no UI)
|
||||
FROM gradle:8.14-jdk21 AS backend-build
|
||||
|
||||
COPY build.gradle .
|
||||
@@ -27,6 +29,7 @@ RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || re
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Build server-only JAR (no frontend, includes security features controlled by DOCKER_ENABLE_SECURITY at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -62,8 +65,7 @@ COPY docker/unified/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/unified/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
|
||||
@@ -10,7 +10,9 @@ COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend .
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true npm run build
|
||||
# Override VITE_API_BASE_URL to use relative paths for production
|
||||
# This ensures frontend works with nginx proxy setup
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true VITE_API_BASE_URL=/ npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM gradle:8.14-jdk21 AS backend-build
|
||||
@@ -76,7 +78,6 @@ ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
TMP=/tmp/stirling-pdf \
|
||||
MODE=BOTH \
|
||||
BACKEND_INTERNAL_PORT=8081 \
|
||||
VITE_API_BASE_URL=http://localhost:8080 \
|
||||
ENDPOINTS_GROUPS_TO_REMOVE=CLI
|
||||
|
||||
# Install minimal dependencies
|
||||
|
||||
@@ -17,7 +17,7 @@ WORKDIR /app
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
|
||||
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -44,8 +44,7 @@ LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
|
||||
@@ -17,7 +17,7 @@ WORKDIR /app
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
|
||||
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -35,8 +35,7 @@ COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
|
||||
@@ -28,8 +28,7 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
ENV HOME=/home/stirlingpdfuser \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
|
||||
@@ -175,4 +175,9 @@
|
||||
#cc-main .pm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
{
|
||||
"identifier": "http:allow-fetch",
|
||||
"allow": [
|
||||
{ "url": "http://localhost:*" },
|
||||
{ "url": "http://127.0.0.1:*" },
|
||||
{ "url": "http://*" },
|
||||
{ "url": "http://*:*" },
|
||||
{ "url": "https://*" }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -398,10 +398,6 @@ pub async fn start_backend(
|
||||
e
|
||||
})?;
|
||||
|
||||
// Wait for the backend to start
|
||||
println!("⏳ Waiting for backend startup...");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10000)).await;
|
||||
|
||||
// Reset the starting flag since startup is complete
|
||||
reset_starting_flag();
|
||||
add_log("✅ Backend startup sequence completed, starting flag cleared".to_string());
|
||||
|
||||
@@ -66,7 +66,7 @@ pub fn run() {
|
||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
||||
let _ = app.emit("files-changed", ());
|
||||
}))
|
||||
.setup(|_app| {
|
||||
.setup(|app| {
|
||||
add_log("🚀 Tauri app setup started".to_string());
|
||||
|
||||
// Process command line arguments on first launch
|
||||
@@ -78,6 +78,17 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Start backend immediately, non-blocking
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
add_log("🚀 Starting bundled backend in background".to_string());
|
||||
let connection_state = app_handle.state::<AppConnectionState>();
|
||||
if let Err(e) = commands::backend::start_backend(app_handle.clone(), connection_state).await {
|
||||
add_log(format!("⚠️ Backend start failed: {}", e));
|
||||
}
|
||||
});
|
||||
|
||||
add_log("🔍 DEBUG: Setup completed".to_string());
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@ import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContex
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
import { BannerProvider } from "@app/contexts/BannerContext";
|
||||
import { CookieConsentProvider } from "@app/contexts/CookieConsentContext";
|
||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
@@ -83,12 +82,11 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<CookieConsentProvider>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
@@ -114,7 +112,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</CookieConsentProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
</BannerProvider>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
@@ -39,7 +38,6 @@ interface ServerLicenseModalHandlers {
|
||||
export function useOnboardingFlow() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext();
|
||||
const { completeTour, tourType, isOpen } = useOnboarding();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -71,7 +69,6 @@ export function useOnboardingFlow() {
|
||||
isOverLimit: false,
|
||||
requiresLicense: false,
|
||||
});
|
||||
const [cookieBannerPending, setCookieBannerPending] = useState(false);
|
||||
const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle');
|
||||
const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null);
|
||||
const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false);
|
||||
@@ -97,29 +94,6 @@ export function useOnboardingFlow() {
|
||||
setToolPromptCompleted(true);
|
||||
}, []);
|
||||
|
||||
const maybeShowCookieBanner = useCallback(() => {
|
||||
if (preferences.hasSeenCookieBanner) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCookieConsentReady || isServerLicenseOpen || serverLicenseIntent !== 'idle' || !toolPromptCompleted) {
|
||||
setCookieBannerPending(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setCookieBannerPending(false);
|
||||
showCookieConsent();
|
||||
updatePreference('hasSeenCookieBanner', true);
|
||||
}, [
|
||||
isCookieConsentReady,
|
||||
isServerLicenseOpen,
|
||||
preferences.hasSeenCookieBanner,
|
||||
serverLicenseIntent,
|
||||
showCookieConsent,
|
||||
toolPromptCompleted,
|
||||
updatePreference,
|
||||
]);
|
||||
|
||||
const requestServerLicense = useCallback(
|
||||
({
|
||||
deferUntilTourComplete = false,
|
||||
@@ -191,25 +165,6 @@ export function useOnboardingFlow() {
|
||||
};
|
||||
}, [requestServerLicense]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
cookieBannerPending &&
|
||||
isCookieConsentReady &&
|
||||
serverLicenseIntent === 'idle' &&
|
||||
!isServerLicenseOpen &&
|
||||
toolPromptCompleted
|
||||
) {
|
||||
maybeShowCookieBanner();
|
||||
}
|
||||
}, [
|
||||
cookieBannerPending,
|
||||
isCookieConsentReady,
|
||||
isServerLicenseOpen,
|
||||
serverLicenseIntent,
|
||||
toolPromptCompleted,
|
||||
maybeShowCookieBanner,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEligibleAdmin =
|
||||
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
|
||||
@@ -269,8 +224,7 @@ export function useOnboardingFlow() {
|
||||
setHasShownServerLicense(true);
|
||||
setServerLicenseIntent('idle');
|
||||
setServerLicenseSource(null);
|
||||
maybeShowCookieBanner();
|
||||
}, [maybeShowCookieBanner]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingSessionMarkedRef.current) {
|
||||
@@ -309,11 +263,9 @@ export function useOnboardingFlow() {
|
||||
setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported'));
|
||||
setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending'));
|
||||
}
|
||||
maybeShowCookieBanner();
|
||||
}, [
|
||||
completeTour,
|
||||
isAdminUser,
|
||||
maybeShowCookieBanner,
|
||||
serverLicenseIntent,
|
||||
serverLicenseSource,
|
||||
tourType,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
|
||||
import { useCookieConsent } from '@app/hooks/useCookieConsent';
|
||||
|
||||
interface FooterProps {
|
||||
privacyPolicy?: string;
|
||||
@@ -20,7 +20,7 @@ export default function Footer({
|
||||
analyticsEnabled = false
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showCookiePreferences } = useCookieConsentContext();
|
||||
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
|
||||
|
||||
// Helper to check if a value is valid (not null/undefined/empty string)
|
||||
const isValidLink = (link?: string) => link && link.trim().length > 0;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useCookieConsent } from '@app/hooks/useCookieConsent';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
interface CookieConsentContextValue {
|
||||
isReady: boolean;
|
||||
showCookieConsent: () => void;
|
||||
showCookiePreferences: () => void;
|
||||
hasResponded: boolean;
|
||||
}
|
||||
|
||||
const CookieConsentContext = createContext<CookieConsentContextValue | undefined>(undefined);
|
||||
|
||||
export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { config } = useAppConfig();
|
||||
const analyticsEnabled = config ? config.enableAnalytics !== false : false;
|
||||
const {
|
||||
showCookieConsent,
|
||||
showCookiePreferences,
|
||||
isInitialized,
|
||||
hasResponded,
|
||||
} = useCookieConsent({ analyticsEnabled });
|
||||
|
||||
const value = useMemo<CookieConsentContextValue>(() => ({
|
||||
isReady: analyticsEnabled && isInitialized,
|
||||
showCookieConsent,
|
||||
showCookiePreferences,
|
||||
hasResponded,
|
||||
}), [analyticsEnabled, hasResponded, isInitialized, showCookieConsent, showCookiePreferences]);
|
||||
|
||||
return (
|
||||
<CookieConsentContext.Provider value={value}>
|
||||
{children}
|
||||
</CookieConsentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCookieConsentContext = (): CookieConsentContextValue => {
|
||||
const context = useContext(CookieConsentContext);
|
||||
if (!context) {
|
||||
throw new Error('useCookieConsentContext must be used within a CookieConsentProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CookieConsent?: {
|
||||
run: (config: any) => void;
|
||||
show: (show?: boolean) => void;
|
||||
hide: () => void;
|
||||
getCookie: (name?: string) => any;
|
||||
acceptedCategory: (category: string) => boolean;
|
||||
acceptedService: (serviceName: string, category: string) => boolean;
|
||||
validConsent?: () => boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,66 +22,47 @@ interface CookieConsentConfig {
|
||||
}
|
||||
|
||||
export const useCookieConsent = ({
|
||||
analyticsEnabled = false,
|
||||
analyticsEnabled = false
|
||||
}: CookieConsentConfig = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { isOpen: tourIsOpen } = useOnboarding();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hasRespondedInternal, setHasRespondedInternal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
if (!analyticsEnabled) {
|
||||
console.log('Cookie consent not enabled - analyticsEnabled is false');
|
||||
return;
|
||||
}
|
||||
|
||||
const markResponded = () => setHasRespondedInternal(true);
|
||||
const removeConsentListeners = () => {
|
||||
window.removeEventListener('cc:onFirstConsent', markResponded);
|
||||
window.removeEventListener('cc:onConsent', markResponded);
|
||||
window.removeEventListener('cc:onChange', markResponded);
|
||||
};
|
||||
|
||||
window.addEventListener('cc:onFirstConsent', markResponded);
|
||||
window.addEventListener('cc:onConsent', markResponded);
|
||||
window.addEventListener('cc:onChange', markResponded);
|
||||
|
||||
if (analyticsEnabled) {
|
||||
setHasRespondedInternal(window.CookieConsent?.validConsent?.() ?? false);
|
||||
// Load the cookie consent CSS files first (always needed)
|
||||
const mainCSS = document.createElement('link');
|
||||
mainCSS.rel = 'stylesheet';
|
||||
mainCSS.href = `${BASE_PATH}css/cookieconsent.css`;
|
||||
if (!document.querySelector(`link[href="${mainCSS.href}"]`)) {
|
||||
document.head.appendChild(mainCSS);
|
||||
}
|
||||
|
||||
if (!analyticsEnabled) {
|
||||
console.log('Cookie consent not enabled - analyticsEnabled is false');
|
||||
setHasRespondedInternal(false);
|
||||
return () => {
|
||||
removeConsentListeners();
|
||||
};
|
||||
const customCSS = document.createElement('link');
|
||||
customCSS.rel = 'stylesheet';
|
||||
customCSS.href = `${BASE_PATH}css/cookieconsentCustomisation.css`;
|
||||
if (!document.querySelector(`link[href="${customCSS.href}"]`)) {
|
||||
document.head.appendChild(customCSS);
|
||||
}
|
||||
|
||||
// Prevent double initialization
|
||||
if (window.CookieConsent) {
|
||||
setIsInitialized(true);
|
||||
if (window.CookieConsent.validConsent?.()) {
|
||||
markResponded();
|
||||
}
|
||||
return () => {
|
||||
removeConsentListeners();
|
||||
};
|
||||
// Force show the modal if it exists but isn't visible
|
||||
setTimeout(() => {
|
||||
window.CookieConsent?.show();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the cookie consent CSS files first
|
||||
const mainCSS = document.createElement('link');
|
||||
mainCSS.rel = 'stylesheet';
|
||||
mainCSS.href = `${BASE_PATH}/css/cookieconsent.css`;
|
||||
document.head.appendChild(mainCSS);
|
||||
|
||||
const customCSS = document.createElement('link');
|
||||
customCSS.rel = 'stylesheet';
|
||||
customCSS.href = `${BASE_PATH}/css/cookieconsentCustomisation.css`;
|
||||
document.head.appendChild(customCSS);
|
||||
|
||||
// Load the cookie consent library
|
||||
const script = document.createElement('script');
|
||||
script.src = `${BASE_PATH}/js/thirdParty/cookieconsent.umd.js`;
|
||||
script.src = `${BASE_PATH}js/thirdParty/cookieconsent.umd.js`;
|
||||
script.onload = () => {
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
@@ -141,7 +124,7 @@ export const useCookieConsent = ({
|
||||
// Initialize cookie consent with full configuration
|
||||
try {
|
||||
window.CookieConsent.run({
|
||||
autoShow: false,
|
||||
autoShow: true,
|
||||
hideFromBots: false,
|
||||
guiOptions: {
|
||||
consentModal: {
|
||||
@@ -227,21 +210,18 @@ export const useCookieConsent = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onFirstConsent: markResponded,
|
||||
onConsent: markResponded,
|
||||
onChange: markResponded,
|
||||
}
|
||||
});
|
||||
|
||||
// Force show after initialization
|
||||
setTimeout(() => {
|
||||
window.CookieConsent?.show();
|
||||
}, 200);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing CookieConsent:', error);
|
||||
}
|
||||
if (window.CookieConsent?.validConsent?.()) {
|
||||
markResponded();
|
||||
} else {
|
||||
setHasRespondedInternal(false);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
setIsInitialized(true);
|
||||
}, 100); // Small delay to ensure DOM is ready
|
||||
};
|
||||
|
||||
@@ -252,8 +232,6 @@ export const useCookieConsent = ({
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
// Cleanup event listeners
|
||||
removeConsentListeners();
|
||||
// Cleanup script and CSS when component unmounts
|
||||
if (document.head.contains(script)) {
|
||||
document.head.removeChild(script);
|
||||
@@ -267,6 +245,24 @@ export const useCookieConsent = ({
|
||||
};
|
||||
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
|
||||
|
||||
// Hide cookie banner when tour is active
|
||||
useEffect(() => {
|
||||
if (!isInitialized || !window.CookieConsent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tourIsOpen) {
|
||||
window.CookieConsent.hide();
|
||||
} else {
|
||||
// Only show if user hasn't made a choice yet
|
||||
const consentCookie = window.CookieConsent.getCookie?.();
|
||||
const hasConsented = consentCookie && Object.keys(consentCookie).length > 0;
|
||||
if (!hasConsented) {
|
||||
window.CookieConsent.show();
|
||||
}
|
||||
}
|
||||
}, [tourIsOpen, isInitialized]);
|
||||
|
||||
const showCookieConsent = useCallback(() => {
|
||||
if (isInitialized && window.CookieConsent) {
|
||||
window.CookieConsent?.show();
|
||||
@@ -286,13 +282,10 @@ export const useCookieConsent = ({
|
||||
return window.CookieConsent.acceptedService(service, category);
|
||||
}, []);
|
||||
|
||||
const effectiveHasResponded = analyticsEnabled ? hasRespondedInternal : true;
|
||||
|
||||
return {
|
||||
showCookieConsent,
|
||||
showCookiePreferences,
|
||||
isServiceAccepted,
|
||||
isInitialized,
|
||||
hasResponded: effectiveHasResponded,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useLogoPath } from "@app/hooks/useLogoPath";
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import { useCookieConsentContext } from "@app/contexts/CookieConsentContext";
|
||||
import { useFileContext } from "@app/contexts/file/fileHooks";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
@@ -49,7 +48,6 @@ export default function HomePage() {
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { config } = useAppConfig();
|
||||
const { hasResponded: cookieConsentResponded } = useCookieConsentContext();
|
||||
const isMobile = useIsMobile();
|
||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||
@@ -64,10 +62,10 @@ export default function HomePage() {
|
||||
|
||||
// Show admin analytics choice modal if analytics settings not configured
|
||||
useEffect(() => {
|
||||
if (config && config.enableAnalytics === null && cookieConsentResponded) {
|
||||
if (config && config.enableAnalytics === null) {
|
||||
setShowAnalyticsModal(true);
|
||||
}
|
||||
}, [config, cookieConsentResponded]);
|
||||
}, [config]);
|
||||
|
||||
// Auto-switch to viewer when going from 0 to 1 file
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
/**
|
||||
* Get the base URL for API requests.
|
||||
* Core version uses simple environment variable.
|
||||
*
|
||||
* Priority:
|
||||
* 1. window.STIRLING_PDF_API_BASE_URL (runtime override - fixes hardcoded localhost issues)
|
||||
* 2. import.meta.env.VITE_API_BASE_URL (build-time env var)
|
||||
* 3. '/' (relative path - works for same-origin deployments)
|
||||
*
|
||||
* Note: Runtime override is needed because VITE_API_BASE_URL gets baked into the build.
|
||||
* If someone builds with VITE_API_BASE_URL=http://localhost:8080, it breaks production deployments.
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
// Runtime override to fix hardcoded localhost in builds
|
||||
if (typeof window !== 'undefined' && (window as any).STIRLING_PDF_API_BASE_URL) {
|
||||
return (window as any).STIRLING_PDF_API_BASE_URL;
|
||||
}
|
||||
|
||||
return import.meta.env.VITE_API_BASE_URL || '/';
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [setupComplete, isFirstLaunch, connectionMode]);
|
||||
|
||||
// Only start bundled backend if in SaaS mode (local backend) and setup is complete
|
||||
// Self-hosted mode connects to remote server so doesn't need local backend
|
||||
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
|
||||
useBackendInitializer(shouldStartBackend);
|
||||
// Initialize monitoring for bundled backend (already started in Rust)
|
||||
// This sets up port detection and health checks
|
||||
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
|
||||
useBackendInitializer(shouldMonitorBackend);
|
||||
|
||||
// Show setup wizard on first launch
|
||||
if (isFirstLaunch && !setupComplete) {
|
||||
@@ -51,23 +51,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<SetupWizard
|
||||
onComplete={async () => {
|
||||
// Wait for backend to become healthy before reloading
|
||||
// This prevents reloading mid-startup which would interrupt the backend
|
||||
const maxWaitTime = 60000; // 60 seconds max
|
||||
const checkInterval = 1000; // Check every second
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
// If we timeout, reload anyway
|
||||
console.warn('[AppProviders] Backend health check timeout, reloading anyway...');
|
||||
onComplete={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -61,7 +61,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
|
||||
await connectionModeService.switchToSaaS(serverConfig.url);
|
||||
await tauriBackendService.startBackend();
|
||||
tauriBackendService.startBackend().catch(console.error);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('SaaS login failed:', err);
|
||||
|
||||
@@ -3,12 +3,22 @@ import { isTauri } from '@tauri-apps/api/core';
|
||||
/**
|
||||
* Desktop override: Determine base URL depending on Tauri environment
|
||||
*
|
||||
* Priority (non-Tauri mode):
|
||||
* 1. window.STIRLING_PDF_API_BASE_URL (runtime override - fixes hardcoded localhost issues)
|
||||
* 2. import.meta.env.VITE_API_BASE_URL (build-time env var)
|
||||
* 3. '/' (relative path - works for same-origin deployments)
|
||||
*
|
||||
* Note: In Tauri mode, the actual URL is determined dynamically by operationRouter
|
||||
* based on connection mode and backend port. This initial baseURL is overridden
|
||||
* by request interceptors in apiClientSetup.ts.
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (!isTauri()) {
|
||||
// Runtime override to fix hardcoded localhost in builds
|
||||
if (typeof window !== 'undefined' && (window as any).STIRLING_PDF_API_BASE_URL) {
|
||||
return (window as any).STIRLING_PDF_API_BASE_URL;
|
||||
}
|
||||
|
||||
return import.meta.env.VITE_API_BASE_URL || '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
Stirling PDF User License
|
||||
|
||||
Copyright (c) 2025 Stirling PDF Inc.
|
||||
|
||||
License Scope & Usage Rights
|
||||
|
||||
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||
|
||||
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||
covering the appropriate number of licensed users.
|
||||
|
||||
Trial and Minimal Use
|
||||
|
||||
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||
|
||||
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||
|
||||
Modifications and Derivative Works
|
||||
|
||||
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||
|
||||
* May not be deployed in production environments without a valid User License;
|
||||
* May not be distributed or sublicensed;
|
||||
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||
|
||||
Prohibited Actions
|
||||
|
||||
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||
|
||||
* Use the Software in production environments;
|
||||
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||
* Remove or alter any licensing or copyright notices;
|
||||
* Circumvent access restrictions or licensing requirements.
|
||||
|
||||
Third-Party Components
|
||||
|
||||
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||
their original license terms as provided by their respective owners.
|
||||
|
||||
Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useCheckout } from '@app/contexts/CheckoutContext';
|
||||
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
||||
@@ -22,7 +21,6 @@ const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const UpgradeBanner: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { hasResponded: cookieChoiceMade } = useCookieConsentContext();
|
||||
const { isOpen: tourOpen } = useOnboarding();
|
||||
const { openCheckout } = useCheckout();
|
||||
const {
|
||||
@@ -159,7 +157,6 @@ const UpgradeBanner: React.FC = () => {
|
||||
shouldShowFriendlyBase &&
|
||||
!licenseLoading &&
|
||||
effectiveTotalUsersLoaded &&
|
||||
cookieChoiceMade &&
|
||||
!tourOpen &&
|
||||
!sessionBlocked,
|
||||
);
|
||||
@@ -168,7 +165,6 @@ const UpgradeBanner: React.FC = () => {
|
||||
: Boolean(
|
||||
shouldShowUrgentBase &&
|
||||
!licenseLoading &&
|
||||
cookieChoiceMade &&
|
||||
!tourOpen &&
|
||||
!sessionBlocked,
|
||||
);
|
||||
|
||||
+26
-5
@@ -13,9 +13,12 @@ import { InfoBanner } from '@app/components/shared/InfoBanner';
|
||||
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
|
||||
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@core/components/shared/config/LoginRequiredBanner';
|
||||
|
||||
const AdminPlanSection: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
||||
const { openCheckout } = useCheckout();
|
||||
const { licenseInfo, refetchLicense } = useLicense();
|
||||
const [currency, setCurrency] = useState<string>(() => {
|
||||
@@ -39,6 +42,11 @@ const AdminPlanSection: React.FC = () => {
|
||||
}, [error]);
|
||||
|
||||
const handleSaveLicense = async () => {
|
||||
// Block save if login is disabled
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingLicense(true);
|
||||
// Allow empty string to clear/remove license
|
||||
@@ -86,6 +94,11 @@ const AdminPlanSection: React.FC = () => {
|
||||
];
|
||||
|
||||
const handleManageClick = useCallback(async () => {
|
||||
// Block access if login is disabled
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Only allow PRO or ENTERPRISE licenses to access billing portal
|
||||
if (!licenseInfo?.licenseType || licenseInfo.licenseType === 'NORMAL') {
|
||||
@@ -112,7 +125,7 @@ const AdminPlanSection: React.FC = () => {
|
||||
body: error.message || 'Please try again or contact support.',
|
||||
});
|
||||
}
|
||||
}, [licenseInfo, t]);
|
||||
}, [licenseInfo, t, validateLoginEnabled]);
|
||||
|
||||
const handleCurrencyChange = useCallback((newCurrency: string) => {
|
||||
setCurrency(newCurrency);
|
||||
@@ -122,6 +135,11 @@ const AdminPlanSection: React.FC = () => {
|
||||
|
||||
const handleUpgradeClick = useCallback(
|
||||
(planGroup: PlanTierGroup) => {
|
||||
// Block access if login is disabled
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow upgrades for server and enterprise tiers
|
||||
if (planGroup.tier === 'free') {
|
||||
return;
|
||||
@@ -151,7 +169,7 @@ const AdminPlanSection: React.FC = () => {
|
||||
},
|
||||
});
|
||||
},
|
||||
[openCheckout, currency, refetch, licenseInfo, t]
|
||||
[openCheckout, currency, refetch, licenseInfo, t, validateLoginEnabled]
|
||||
);
|
||||
|
||||
const shouldShowLicenseWarning = licenseAlert.active && licenseAlert.audience === 'admin';
|
||||
@@ -200,7 +218,9 @@ const AdminPlanSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{shouldShowLicenseWarning && (
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
{shouldShowLicenseWarning && (
|
||||
<InfoBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
@@ -231,6 +251,7 @@ const AdminPlanSection: React.FC = () => {
|
||||
currency={currency}
|
||||
onCurrencyChange={handleCurrencyChange}
|
||||
currencyOptions={currencyOptions}
|
||||
loginEnabled={loginEnabled}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
@@ -288,11 +309,11 @@ const AdminPlanSection: React.FC = () => {
|
||||
onChange={(e) => setLicenseKeyInput(e.target.value)}
|
||||
placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
|
||||
type="password"
|
||||
disabled={savingLicense}
|
||||
disabled={!loginEnabled || savingLicense}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSaveLicense} loading={savingLicense} size="sm">
|
||||
<Button onClick={handleSaveLicense} loading={savingLicense} size="sm" disabled={!loginEnabled}>
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
+4
@@ -15,6 +15,7 @@ interface AvailablePlansSectionProps {
|
||||
currency?: string;
|
||||
onCurrencyChange?: (currency: string) => void;
|
||||
currencyOptions?: { value: string; label: string }[];
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
@@ -25,6 +26,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
currency,
|
||||
onCurrencyChange,
|
||||
currencyOptions,
|
||||
loginEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
@@ -91,6 +93,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
clearable={false}
|
||||
w={300}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@@ -113,6 +116,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
currentTier={currentTier}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
onManageClick={onManageClick}
|
||||
loginEnabled={loginEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,10 @@ interface PlanCardProps {
|
||||
currentTier?: 'free' | 'server' | 'enterprise' | null;
|
||||
onUpgradeClick: (planGroup: PlanTierGroup) => void;
|
||||
onManageClick?: () => void;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, currentTier, onUpgradeClick, onManageClick }) => {
|
||||
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, currentTier, onUpgradeClick, onManageClick, loginEnabled = true }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Render Free plan
|
||||
@@ -174,7 +175,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
||||
variant={isCurrentTier ? 'filled' : isDowngrade ? 'filled' : isEnterpriseBlockedForFree ? 'light' : 'filled'}
|
||||
fullWidth
|
||||
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
|
||||
disabled={isDowngrade || isEnterpriseBlockedForFree}
|
||||
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}
|
||||
>
|
||||
{isCurrentTier
|
||||
? t('plan.manage', 'Manage')
|
||||
|
||||
Reference in New Issue
Block a user