mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
9
Commits
v2.0.1
...
swaggerFixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
408f6224fb | ||
|
|
4784e4f671 | ||
|
|
058a81d554 | ||
|
|
e4c6ce5836 | ||
|
|
250979e271 | ||
|
|
731743b618 | ||
|
|
04c4aec0d8 | ||
|
|
f63df148ad | ||
|
|
6f0be94bd6 |
@@ -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.
|
||||
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Analysis",
|
||||
description =
|
||||
"""
|
||||
Document analysis and information extraction services for content intelligence and insights.
|
||||
Read-only inspection of PDFs: page count, page sizes, fonts, form fields, annotations, document properties, and security details.
|
||||
Use these endpoints to understand what's inside a document without changing it.
|
||||
|
||||
This endpoint group provides analytical capabilities to understand document structure,
|
||||
extract information, and generate insights from PDF content for automated processing.
|
||||
|
||||
Common use cases:
|
||||
• Document inventory management and content audit for compliance verification
|
||||
• Quality assurance workflows and business intelligence analytics
|
||||
• Migration planning, accessibility evaluation, and document forensics
|
||||
|
||||
Business applications:
|
||||
• Legal discovery, financial document review, and healthcare records analysis
|
||||
• Academic research, government processing, and publishing optimization
|
||||
|
||||
Operational scenarios:
|
||||
• Large-scale profiling, migration assessment, and performance optimization
|
||||
• Automated quality control and content strategy development
|
||||
|
||||
Target users: Data analysts, QA teams, administrators, and business intelligence
|
||||
professionals requiring detailed document insights.
|
||||
Typical uses:
|
||||
• Get page counts and dimensions for layout or print rules
|
||||
• List fonts and annotations to spot compatibility issues
|
||||
• Inspect form fields before deciding how to fill or modify them
|
||||
• Pull metadata and security settings for audits or reports
|
||||
""")
|
||||
public @interface AnalysisApi {}
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Convert",
|
||||
description =
|
||||
"""
|
||||
Document format transformation services for cross-platform compatibility and workflow integration.
|
||||
Convert PDFs to and from other formats (Word, images, HTML, Markdown, PDF/A, CBZ/CBR, EML, etc.).
|
||||
This group also powers the text-editor / jobId-based editing flow for incremental PDF edits.
|
||||
|
||||
This endpoint group enables transformation between various formats, supporting
|
||||
diverse business workflows and system integrations for mixed document ecosystems.
|
||||
|
||||
Common use cases:
|
||||
• Legacy system integration, document migration, and cross-platform sharing
|
||||
• Archive standardization, publishing preparation, and content adaptation
|
||||
• Accessibility compliance and mobile-friendly document preparation
|
||||
|
||||
Business applications:
|
||||
• Enterprise content management, digital publishing, and educational platforms
|
||||
• Legal document processing, healthcare interoperability, and government standardization
|
||||
|
||||
Integration scenarios:
|
||||
• API-driven pipelines, automated workflow preparation, and batch conversions
|
||||
• Real-time format adaptation for user requests
|
||||
|
||||
Target users: System integrators, content managers, digital archivists, and
|
||||
organizations requiring flexible document format interoperability.
|
||||
Typical uses:
|
||||
• Turn PDFs into Word or text for editing
|
||||
• Convert office files, images, HTML, or email (EML) into PDFs
|
||||
• Create PDF/A for long-term archiving
|
||||
• Export PDFs as images, HTML, CSV, or Markdown for search, analysis, or reuse
|
||||
""")
|
||||
public @interface ConvertApi {}
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Filter",
|
||||
description =
|
||||
"""
|
||||
Document content filtering and search operations for information discovery and organization.
|
||||
Check basic properties of PDFs before you process them: page count, file size, page size/rotation, and whether they contain text or images.
|
||||
Use these endpoints as a "pre-check" step to decide what to do with a file next.
|
||||
|
||||
This endpoint group enables intelligent content discovery and organization within
|
||||
document collections for content-based processing and information extraction.
|
||||
|
||||
Common use cases:
|
||||
• Legal discovery, research organization, and compliance auditing
|
||||
• Content moderation, academic research, and business intelligence
|
||||
• Quality assurance and content validation workflows
|
||||
|
||||
Business applications:
|
||||
• Contract analysis, financial review, and healthcare records organization
|
||||
• Government processing, educational curation, and IP protection
|
||||
|
||||
Workflow scenarios:
|
||||
• Large-scale processing, automated classification, and information extraction
|
||||
• Document preparation for further processing or analysis
|
||||
|
||||
Target users: Legal professionals, researchers, compliance officers, and
|
||||
organizations requiring intelligent document content discovery and organization.
|
||||
Typical uses:
|
||||
• Reject files that are too big or too small
|
||||
• Detect image-only PDFs that should go through OCR
|
||||
• Ensure a document has enough pages before it enters a workflow
|
||||
• Check orientation of pages before printing or merging
|
||||
""")
|
||||
public @interface FilterApi {}
|
||||
|
||||
@@ -22,21 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "General",
|
||||
description =
|
||||
"""
|
||||
Core PDF processing operations for fundamental document manipulation workflows.
|
||||
Page-level PDF editing: split, merge, rotate, crop, rearrange, and scale pages.
|
||||
These endpoints handle most daily "I opened a PDF editor just to…" type tasks.
|
||||
|
||||
This endpoint group provides essential PDF functionality that forms the foundation
|
||||
of most document processing workflows across various industries.
|
||||
|
||||
Common use cases:
|
||||
• Document preparation for archival systems and content organization
|
||||
• File preparation for distribution, accessibility compliance, and batch processing
|
||||
• Document consolidation for reporting and legal compliance workflows
|
||||
|
||||
Typical applications:
|
||||
• Content management, publishing workflows, and educational content distribution
|
||||
• Business process automation and archive management
|
||||
|
||||
Target users: Content managers, document processors, and organizations requiring
|
||||
reliable foundational PDF manipulation capabilities.
|
||||
Typical uses:
|
||||
• Split a large PDF into smaller files (by pages, chapters, or size)
|
||||
• Merge several PDFs into one report or pack
|
||||
• Rotate or reorder pages before sending or archiving
|
||||
• Turn a multi-page document into one long scrolling page
|
||||
""")
|
||||
public @interface GeneralApi {}
|
||||
|
||||
@@ -22,25 +22,15 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Misc",
|
||||
description =
|
||||
"""
|
||||
Specialized utilities and supplementary tools for enhanced document processing workflows.
|
||||
Tools that don't fit neatly elsewhere: OCR, compress, repair, flatten, extract images, update metadata, add stamps/page numbers/images, and more.
|
||||
These endpoints help fix problem PDFs and prepare them for sharing, storage, or further processing.
|
||||
|
||||
This endpoint group provides utility operations that support core document processing
|
||||
tasks and address specific workflow needs in real-world scenarios.
|
||||
|
||||
Common use cases:
|
||||
• Document optimization for bandwidth-limited environments and storage cost management
|
||||
• Document repair, content extraction, and validation for quality assurance
|
||||
• Accessibility improvement and custom processing for specialized needs
|
||||
|
||||
Business applications:
|
||||
• Web publishing optimization, email attachment management, and archive efficiency
|
||||
• Mobile compatibility, print production, and legacy document recovery
|
||||
|
||||
Operational scenarios:
|
||||
• Batch processing, quality control, and performance optimization
|
||||
• Troubleshooting and recovery of problematic documents
|
||||
|
||||
Target users: System administrators, document specialists, and organizations requiring
|
||||
specialized document processing and optimization tools.
|
||||
Typical uses:
|
||||
• Repair a damaged PDF or remove blank pages
|
||||
• Run OCR on scanned PDFs so they become searchable
|
||||
• Compress large PDFs for email or web download
|
||||
• Extract embedded images or scans
|
||||
• Add page numbers, stamps, or overlay an image (e.g. logo, seal)
|
||||
• Update PDF metadata (title, author, etc.)
|
||||
""")
|
||||
public @interface MiscApi {}
|
||||
|
||||
@@ -22,25 +22,12 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Pipeline",
|
||||
description =
|
||||
"""
|
||||
Automated document processing workflows for complex multi-stage business operations.
|
||||
Run several PDF operations in one configured pipeline instead of calling multiple endpoints yourself.
|
||||
Useful when you always do the same steps in sequence (for example: convert → OCR → compress → watermark).
|
||||
|
||||
This endpoint group enables organizations to create sophisticated document processing
|
||||
workflows that combine multiple operations into streamlined, repeatable processes.
|
||||
|
||||
Common use cases:
|
||||
• Invoice processing, legal document review, and healthcare records standardization
|
||||
• Government processing, educational content preparation, and publishing automation
|
||||
• Contract lifecycle management and approval processes
|
||||
|
||||
Business applications:
|
||||
• Automated compliance reporting, large-scale migration, and quality assurance
|
||||
• Archive preparation, content delivery, and document approval workflows
|
||||
|
||||
Operational scenarios:
|
||||
• Scheduled batch processing and event-driven document processing
|
||||
• Multi-department coordination and business system integration
|
||||
|
||||
Target users: Business process managers, IT automation specialists, and organizations
|
||||
requiring consistent, repeatable document processing workflows.
|
||||
Typical uses:
|
||||
• Process incoming invoices in one go (clean, OCR, compress, stamp, etc.)
|
||||
• Normalise documents before they enter an archive
|
||||
• Wrap a complex document flow behind a single API call for your own apps
|
||||
""")
|
||||
public @interface PipelineApi {}
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Security",
|
||||
description =
|
||||
"""
|
||||
Document security and protection services for confidential and sensitive content.
|
||||
Protect and clean PDFs: passwords, digital signatures, redaction, and sanitizing.
|
||||
These endpoints help you control who can open a file, what they can do with it, and remove sensitive content when needed.
|
||||
|
||||
This endpoint group provides essential security operations for organizations handling
|
||||
sensitive documents and materials requiring controlled access.
|
||||
|
||||
Common use cases:
|
||||
• Legal confidentiality, healthcare privacy (HIPAA), and financial regulatory compliance
|
||||
• Government classified handling, corporate IP protection, and educational privacy (FERPA)
|
||||
• Contract security for business transactions
|
||||
|
||||
Business applications:
|
||||
• Document authentication, confidential sharing, and secure archiving
|
||||
• Content watermarking, access control, and privacy protection through redaction
|
||||
|
||||
Industry scenarios:
|
||||
• Legal discovery, medical records exchange, financial audit documentation
|
||||
• Enterprise policy enforcement and data governance
|
||||
|
||||
Target users: Legal professionals, healthcare administrators, compliance officers,
|
||||
government agencies, and enterprises handling sensitive content.
|
||||
Typical uses:
|
||||
• Add or remove a password on a PDF
|
||||
• Redact personal or confidential information (manually or automatically)
|
||||
• Validate or remove digital signatures
|
||||
• Sanitize a PDF to strip scripts and embedded content
|
||||
""")
|
||||
public @interface SecurityApi {}
|
||||
|
||||
@@ -159,6 +159,8 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|
||||
// enableLogin)
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|
||||
@@ -21,6 +21,9 @@ public class SpringDocConfig {
|
||||
"/api/v1/user/**",
|
||||
"/api/v1/settings/**",
|
||||
"/api/v1/team/**",
|
||||
"/api/v1/auth/**",
|
||||
"/api/v1/invite/**",
|
||||
"/api/v1/audit/**",
|
||||
"/api/v1/ui-data/**",
|
||||
"/api/v1/proprietary/ui-data/**",
|
||||
"/api/v1/info/**",
|
||||
@@ -33,7 +36,7 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Processing API")
|
||||
.description(
|
||||
"API documentation for PDF processing operations including conversion, manipulation, security, and utilities."));
|
||||
"APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -47,14 +50,17 @@ public class SpringDocConfig {
|
||||
"/api/v1/admin/**",
|
||||
"/api/v1/user/**",
|
||||
"/api/v1/settings/**",
|
||||
"/api/v1/team/**")
|
||||
"/api/v1/team/**",
|
||||
"/api/v1/auth/**",
|
||||
"/api/v1/invite/**",
|
||||
"/api/v1/audit/**")
|
||||
.addOpenApiCustomizer(
|
||||
openApi -> {
|
||||
openApi.info(
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Admin API")
|
||||
.title("Stirling PDF - Management API")
|
||||
.description(
|
||||
"API documentation for administrative functions, user management, and system configuration."));
|
||||
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -76,7 +82,7 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - System API")
|
||||
.description(
|
||||
"API documentation for system information, UI data, and utility endpoints."));
|
||||
"System information, UI metadata, job status, and file management endpoints."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -58,6 +58,21 @@ public class UIDataController {
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
}
|
||||
|
||||
@GetMapping("/footer-info")
|
||||
@Operation(summary = "Get public footer configuration data")
|
||||
public ResponseEntity<FooterData> getFooterData() {
|
||||
FooterData data = new FooterData();
|
||||
data.setAnalyticsEnabled(applicationProperties.getSystem().getEnableAnalytics());
|
||||
data.setTermsAndConditions(applicationProperties.getLegal().getTermsAndConditions());
|
||||
data.setPrivacyPolicy(applicationProperties.getLegal().getPrivacyPolicy());
|
||||
data.setAccessibilityStatement(
|
||||
applicationProperties.getLegal().getAccessibilityStatement());
|
||||
data.setCookiePolicy(applicationProperties.getLegal().getCookiePolicy());
|
||||
data.setImpressum(applicationProperties.getLegal().getImpressum());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@GetMapping("/home")
|
||||
@Operation(summary = "Get home page data")
|
||||
public ResponseEntity<HomeData> getHomeData() {
|
||||
@@ -237,6 +252,16 @@ public class UIDataController {
|
||||
}
|
||||
|
||||
// Data classes
|
||||
@Data
|
||||
public static class FooterData {
|
||||
private Boolean analyticsEnabled;
|
||||
private String termsAndConditions;
|
||||
private String privacyPolicy;
|
||||
private String accessibilityStatement;
|
||||
private String cookiePolicy;
|
||||
private String impressum;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class HomeData {
|
||||
private boolean showSurveyFromDocker;
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
|
||||
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
|
||||
|
||||
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
|
||||
|
||||
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
|
||||
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
|
||||
@@ -34,10 +34,10 @@
|
||||
--cc-section-category-border: var(--md-sys-color-outline);
|
||||
|
||||
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
|
||||
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
*/
|
||||
--cc-footer-bg: var(--cc-bg);
|
||||
--cc-footer-color: var(--cc-primary-color);
|
||||
--cc-footer-border-color: var(--cc-bg);
|
||||
--cc-footer-border-color: var(--cc-bg);
|
||||
}
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
@@ -81,4 +81,4 @@
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -34,7 +34,19 @@ import stirling.software.proprietary.util.FormUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/form")
|
||||
@Tag(name = "Forms", description = "PDF form APIs")
|
||||
@Tag(
|
||||
name = "Forms",
|
||||
description =
|
||||
"""
|
||||
Work with PDF form fields: read them, fill them, edit them, or remove them.
|
||||
Treats a PDF as a structured form instead of just flat pages.
|
||||
|
||||
Typical uses:
|
||||
• Inspect which form fields exist in a PDF
|
||||
• Autofill forms from your own systems (e.g. CRM, ERP)
|
||||
• Change or delete form fields before sending out a final, non-editable copy
|
||||
• Unlock read-only form fields when you need to update them
|
||||
""")
|
||||
@RequiredArgsConstructor
|
||||
public class FormFillController {
|
||||
|
||||
|
||||
+2
-1
@@ -299,7 +299,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
contextPath + "/api/v1/auth/refresh",
|
||||
contextPath + "/api/v1/auth/me",
|
||||
contextPath + "/api/v1/invite/validate",
|
||||
contextPath + "/api/v1/invite/accept"
|
||||
contextPath + "/api/v1/invite/accept",
|
||||
contextPath + "/api/v1/ui-data/footer-info"
|
||||
};
|
||||
|
||||
for (String pattern : publicApiPatterns) {
|
||||
|
||||
@@ -54,17 +54,17 @@
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #2d2d2d;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #e5e5e5;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #e5e5e5;
|
||||
--cc-btn-secondary-color: #ffffff;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #2d2d2d;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
|
||||
--cc-separator-border-color: #555555;
|
||||
@@ -180,4 +180,27 @@
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Ensure consent modal text is visible in both themes */
|
||||
#cc-main .cm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__desc {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer-links a,
|
||||
#cc-main .cm__link {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
@@ -5777,7 +5777,7 @@ description = "Sign in with your Stirling account"
|
||||
|
||||
[setup.mode.selfhosted]
|
||||
title = "Self-Hosted Server"
|
||||
description = "Connect to your own Stirling PDF server"
|
||||
description = "Connect to your own Stirling PDF server with your personal account"
|
||||
|
||||
[setup.saas]
|
||||
title = "Sign in to Stirling"
|
||||
@@ -5813,6 +5813,13 @@ submit = "Login"
|
||||
signInWith = "Sign in with"
|
||||
oauthPending = "Opening browser for authentication..."
|
||||
orContinueWith = "Or continue with email"
|
||||
serverRequirement = "Note: The server must have login enabled."
|
||||
showInstructions = "How to enable?"
|
||||
hideInstructions = "Hide instructions"
|
||||
instructions = "To enable login on your Stirling PDF server:"
|
||||
instructionsEnvVar = "Set the environment variable:"
|
||||
instructionsOrYml = "Or in settings.yml:"
|
||||
instructionsRestart = "Then restart your server for the changes to take effect."
|
||||
|
||||
[setup.login.username]
|
||||
label = "Username"
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsent } from '@app/hooks/useCookieConsent';
|
||||
import { useFooterInfo } from '@app/hooks/useFooterInfo';
|
||||
|
||||
interface FooterProps {
|
||||
privacyPolicy?: string;
|
||||
@@ -9,6 +10,7 @@ interface FooterProps {
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
analyticsEnabled?: boolean;
|
||||
forceLightMode?: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({
|
||||
@@ -17,10 +19,36 @@ export default function Footer({
|
||||
accessibilityStatement,
|
||||
cookiePolicy,
|
||||
impressum,
|
||||
analyticsEnabled = false
|
||||
analyticsEnabled,
|
||||
forceLightMode = false
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
|
||||
const { footerInfo } = useFooterInfo();
|
||||
|
||||
console.log('[Footer] Props analyticsEnabled:', analyticsEnabled);
|
||||
console.log('[Footer] Fetched footerInfo:', footerInfo);
|
||||
|
||||
// Use props if provided, otherwise fall back to fetched footer info
|
||||
const finalAnalyticsEnabled = analyticsEnabled ?? footerInfo?.analyticsEnabled ?? false;
|
||||
const finalPrivacyPolicy = privacyPolicy ?? footerInfo?.privacyPolicy;
|
||||
const finalTermsAndConditions = termsAndConditions ?? footerInfo?.termsAndConditions;
|
||||
const finalAccessibilityStatement = accessibilityStatement ?? footerInfo?.accessibilityStatement;
|
||||
const finalCookiePolicy = cookiePolicy ?? footerInfo?.cookiePolicy;
|
||||
const finalImpressum = impressum ?? footerInfo?.impressum;
|
||||
|
||||
console.log('[Footer] Final analyticsEnabled:', finalAnalyticsEnabled);
|
||||
|
||||
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled: finalAnalyticsEnabled, forceLightMode });
|
||||
|
||||
// Default URLs
|
||||
const defaultTermsUrl = "https://www.stirling.com/legal/terms-of-service";
|
||||
const defaultPrivacyUrl = "https://www.stirling.com/legal/privacy-policy";
|
||||
const defaultAccessibilityUrl = "https://www.stirling.com/accessibility";
|
||||
|
||||
// Use provided URLs or fall back to defaults
|
||||
const finalTermsUrl = finalTermsAndConditions || defaultTermsUrl;
|
||||
const finalPrivacyUrl = finalPrivacyPolicy || defaultPrivacyUrl;
|
||||
const finalAccessibilityUrl = finalAccessibilityStatement || defaultAccessibilityUrl;
|
||||
|
||||
// Helper to check if a value is valid (not null/undefined/empty string)
|
||||
const isValidLink = (link?: string) => link && link.trim().length > 0;
|
||||
@@ -28,8 +56,8 @@ export default function Footer({
|
||||
return (
|
||||
<div style={{
|
||||
height: 'var(--footer-height)',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderTop: '1px solid var(--mantine-color-gray-2)',
|
||||
backgroundColor: forceLightMode ? '#f1f3f5' : 'var(--mantine-color-gray-1)',
|
||||
borderTop: forceLightMode ? '1px solid #e9ecef' : '1px solid var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
@@ -38,7 +66,10 @@ export default function Footer({
|
||||
justify="center"
|
||||
align="center"
|
||||
direction="row"
|
||||
style={{ fontSize: '0.75rem' }}>
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: forceLightMode ? '#495057' : undefined
|
||||
}}>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
id="survey"
|
||||
@@ -48,57 +79,51 @@ export default function Footer({
|
||||
>
|
||||
{t('survey.nav', 'Survey')}
|
||||
</a>
|
||||
{isValidLink(privacyPolicy) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={finalPrivacyUrl}
|
||||
>
|
||||
{t('legal.privacy', 'Privacy Policy')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={finalTermsUrl}
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={finalAccessibilityUrl}
|
||||
>
|
||||
{t('legal.accessibility', 'Accessibility')}
|
||||
</a>
|
||||
{isValidLink(finalCookiePolicy) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={privacyPolicy}
|
||||
>
|
||||
{t('legal.privacy', 'Privacy Policy')}
|
||||
</a>
|
||||
)}
|
||||
{isValidLink(termsAndConditions) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={termsAndConditions}
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
)}
|
||||
{isValidLink(accessibilityStatement) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={accessibilityStatement}
|
||||
>
|
||||
{t('legal.accessibility', 'Accessibility')}
|
||||
</a>
|
||||
)}
|
||||
{isValidLink(cookiePolicy) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={cookiePolicy}
|
||||
href={finalCookiePolicy}
|
||||
>
|
||||
{t('legal.cookie', 'Cookie Policy')}
|
||||
</a>
|
||||
)}
|
||||
{isValidLink(impressum) && (
|
||||
{isValidLink(finalImpressum) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={impressum}
|
||||
href={finalImpressum}
|
||||
>
|
||||
{t('legal.impressum', 'Impressum')}
|
||||
</a>
|
||||
)}
|
||||
{analyticsEnabled && (
|
||||
{finalAnalyticsEnabled && (
|
||||
<button
|
||||
className="footer-link px-3"
|
||||
id="cookieBanner"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Menu, Button, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { Menu, Button, ActionIcon } from '@mantine/core';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '@app/i18n';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
@@ -11,6 +12,7 @@ interface LanguageSelectorProps {
|
||||
position?: React.ComponentProps<typeof Menu>['position'];
|
||||
offset?: number;
|
||||
compact?: boolean; // icon-only trigger
|
||||
tooltip?: string; // tooltip text for compact mode
|
||||
}
|
||||
|
||||
interface LanguageOption {
|
||||
@@ -51,7 +53,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const label = disabled ? (
|
||||
<Tooltip label={t('comingSoon', 'Coming soon')} position="left" withArrow>
|
||||
<Tooltip content={t('comingSoon', 'Coming soon')} position="left" arrow>
|
||||
<p>{option.label}</p>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -64,7 +66,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
style={{
|
||||
opacity: animationTriggered ? 1 : 0,
|
||||
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
|
||||
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
|
||||
transition: `opacity 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s, transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -90,7 +92,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
: isSelected
|
||||
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
||||
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
|
||||
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
transition: 'all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
'&:hover': !disabled ? {
|
||||
backgroundColor: isSelected
|
||||
@@ -126,7 +128,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
backgroundColor: 'var(--mantine-color-blue-4)',
|
||||
opacity: 0.6,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
animation: 'ripple-expand 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
@@ -149,7 +151,12 @@ const RippleStyles: React.FC = () => (
|
||||
);
|
||||
|
||||
// Main component
|
||||
const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-start', offset = 8, compact = false }) => {
|
||||
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
position = 'bottom-start',
|
||||
offset = 8,
|
||||
compact = false,
|
||||
tooltip
|
||||
}) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||
@@ -184,14 +191,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
setOpened(false);
|
||||
|
||||
// Clear ripple effect
|
||||
setTimeout(() => setRippleEffect(null), 100);
|
||||
setTimeout(() => setRippleEffect(null), 50);
|
||||
|
||||
// Force a full reload so RTL/LTR layout and tooltips re-evaluate correctly
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 300);
|
||||
}, 200);
|
||||
}, 150);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
@@ -202,7 +209,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
if (opened) {
|
||||
setAnimationTriggered(false);
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => setAnimationTriggered(true), 50);
|
||||
setTimeout(() => setAnimationTriggered(true), 20);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
@@ -218,7 +225,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
transitionProps={{
|
||||
transition: 'scale-y',
|
||||
duration: 200,
|
||||
duration: 120,
|
||||
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
}}
|
||||
>
|
||||
@@ -227,8 +234,8 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
title={currentLanguage}
|
||||
className="right-rail-icon"
|
||||
title={!opened && tooltip ? tooltip : undefined}
|
||||
styles={{
|
||||
root: {
|
||||
color: 'var(--right-rail-icon)',
|
||||
@@ -274,8 +281,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
}}
|
||||
>
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => {
|
||||
const enabledLanguages = [
|
||||
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
|
||||
@@ -301,8 +307,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
|
||||
@@ -216,14 +216,12 @@ export default function RightRail() {
|
||||
tooltipOffset
|
||||
)}
|
||||
|
||||
{renderWithTooltip(
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
</div>,
|
||||
t('rightRail.language', 'Language'),
|
||||
tooltipPosition,
|
||||
tooltipOffset
|
||||
)}
|
||||
<LanguageSelector
|
||||
position="left-start"
|
||||
offset={6}
|
||||
compact
|
||||
tooltip={t('rightRail.language', 'Language')}
|
||||
/>
|
||||
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
|
||||
@@ -19,10 +19,12 @@ declare global {
|
||||
|
||||
interface CookieConsentConfig {
|
||||
analyticsEnabled?: boolean;
|
||||
forceLightMode?: boolean;
|
||||
}
|
||||
|
||||
export const useCookieConsent = ({
|
||||
analyticsEnabled = false
|
||||
analyticsEnabled = false,
|
||||
forceLightMode = false
|
||||
}: CookieConsentConfig = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
@@ -69,6 +71,12 @@ export const useCookieConsent = ({
|
||||
|
||||
// Detect current theme and set appropriate mode
|
||||
const detectTheme = () => {
|
||||
// If forceLightMode is enabled, always use light mode
|
||||
if (forceLightMode) {
|
||||
document.documentElement.classList.remove('cc--darkmode');
|
||||
return false;
|
||||
}
|
||||
|
||||
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||
const hasLightClass = document.documentElement.classList.contains('light');
|
||||
const hasDarkClass = document.documentElement.classList.contains('dark');
|
||||
@@ -104,21 +112,24 @@ export const useCookieConsent = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
const themeObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' &&
|
||||
(mutation.attributeName === 'data-mantine-color-scheme' ||
|
||||
mutation.attributeName === 'class')) {
|
||||
detectTheme();
|
||||
}
|
||||
// Listen for theme changes (but not if forceLightMode is enabled)
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
if (!forceLightMode) {
|
||||
themeObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' &&
|
||||
(mutation.attributeName === 'data-mantine-color-scheme' ||
|
||||
mutation.attributeName === 'class')) {
|
||||
detectTheme();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-mantine-color-scheme', 'class']
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-mantine-color-scheme', 'class']
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Initialize cookie consent with full configuration
|
||||
@@ -245,6 +256,65 @@ export const useCookieConsent = ({
|
||||
};
|
||||
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
|
||||
|
||||
// Update theme when forceLightMode changes
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
const detectTheme = () => {
|
||||
if (forceLightMode) {
|
||||
document.documentElement.classList.remove('cc--darkmode');
|
||||
return false;
|
||||
}
|
||||
|
||||
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||
const hasLightClass = document.documentElement.classList.contains('light');
|
||||
const hasDarkClass = document.documentElement.classList.contains('dark');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
let isDarkMode = false;
|
||||
if (mantineScheme) {
|
||||
isDarkMode = mantineScheme === 'dark';
|
||||
} else if (hasLightClass) {
|
||||
isDarkMode = false;
|
||||
} else if (hasDarkClass) {
|
||||
isDarkMode = true;
|
||||
} else {
|
||||
isDarkMode = systemPrefersDark;
|
||||
}
|
||||
|
||||
document.documentElement.classList.toggle('cc--darkmode', isDarkMode);
|
||||
return isDarkMode;
|
||||
};
|
||||
|
||||
// Update theme immediately
|
||||
detectTheme();
|
||||
|
||||
// Set up or remove theme observer based on forceLightMode
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
if (!forceLightMode) {
|
||||
themeObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' &&
|
||||
(mutation.attributeName === 'data-mantine-color-scheme' ||
|
||||
mutation.attributeName === 'class')) {
|
||||
detectTheme();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-mantine-color-scheme', 'class']
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [forceLightMode, isInitialized]);
|
||||
|
||||
// Hide cookie banner when tour is active
|
||||
useEffect(() => {
|
||||
if (!isInitialized || !window.CookieConsent) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface FooterInfo {
|
||||
analyticsEnabled?: boolean;
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch public footer configuration data.
|
||||
* This endpoint is always accessible without authentication.
|
||||
*/
|
||||
export function useFooterInfo() {
|
||||
const [footerInfo, setFooterInfo] = useState<FooterInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFooterInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('[useFooterInfo] Fetching footer info from /api/v1/ui-data/footer-info...');
|
||||
const response = await apiClient.get<FooterInfo>('/api/v1/ui-data/footer-info', {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
console.log('[useFooterInfo] Footer info received:', response.data);
|
||||
setFooterInfo(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[useFooterInfo] Failed to fetch footer info:', err);
|
||||
setError(err as Error);
|
||||
// Set defaults on error
|
||||
setFooterInfo({
|
||||
analyticsEnabled: false,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFooterInfo();
|
||||
}, []);
|
||||
|
||||
return { footerInfo, loading, error };
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group } from '@mantine/core';
|
||||
import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group, Collapse, Anchor, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
@@ -19,6 +19,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false,
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -92,6 +93,47 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false,
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
|
||||
</Text>
|
||||
|
||||
{/* Login requirement note for self-hosted servers */}
|
||||
{!isSaaS && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('setup.login.serverRequirement', 'Note: The server must have login enabled.')}{' '}
|
||||
<Anchor
|
||||
size="xs"
|
||||
onClick={() => setShowInstructions(!showInstructions)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{showInstructions
|
||||
? t('setup.login.hideInstructions', 'Hide instructions')
|
||||
: t('setup.login.showInstructions', 'How to enable?')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Collapse in={showInstructions}>
|
||||
<Box mt="xs" p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: '4px' }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('setup.login.instructions', 'To enable login on your Stirling PDF server:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsEnvVar', 'Set the environment variable:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
||||
SECURITY_ENABLELOGIN=true
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsOrYml', 'Or in settings.yml:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
||||
security.enableLogin: true
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsRestart', 'Then restart your server for the changes to take effect.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* OAuth Login Buttons - Only show for SaaS */}
|
||||
{isSaaS && (
|
||||
<>
|
||||
|
||||
@@ -63,7 +63,7 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server')}
|
||||
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server with your personal account')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -101,8 +101,8 @@ export class TauriBackendService {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
private async waitForPort(maxAttempts = 30): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
private async waitForPort(): Promise<void> {
|
||||
while (true) {
|
||||
try {
|
||||
const port = await invoke<number | null>('get_backend_port');
|
||||
if (port) {
|
||||
@@ -114,7 +114,6 @@ export class TauriBackendService {
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error('Failed to detect backend port after 15 seconds');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,16 +207,14 @@ export class TauriBackendService {
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForHealthy(maxAttempts = 60): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
private async waitForHealthy(): Promise<void> {
|
||||
while (true) {
|
||||
const isHealthy = await this.checkBackendHealth();
|
||||
if (isHealthy) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
this.setStatus('unhealthy');
|
||||
throw new Error('Backend failed to become healthy after 60 seconds');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
+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')
|
||||
|
||||
@@ -7,6 +7,7 @@ import Login from '@app/routes/Login';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
|
||||
import { OnboardingProvider } from '@app/contexts/OnboardingContext';
|
||||
|
||||
// Mock i18n to return fallback text
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -65,7 +66,9 @@ vi.mock('react-router-dom', async () => {
|
||||
// Test wrapper with MantineProvider
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>
|
||||
<PreferencesProvider>{children}</PreferencesProvider>
|
||||
<PreferencesProvider>
|
||||
<OnboardingProvider>{children}</OnboardingProvider>
|
||||
</PreferencesProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
|
||||
import buildLoginSlides from '@app/components/shared/loginSlides';
|
||||
import styles from '@app/routes/authShared/AuthLayout.module.css';
|
||||
import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
||||
import Footer from '@app/components/shared/Footer';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode
|
||||
@@ -67,6 +68,9 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, width: '100%', zIndex: 10 }}>
|
||||
<Footer forceLightMode={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,18 @@ export default defineConfig(({ mode }) => {
|
||||
secure: false,
|
||||
xfwd: true,
|
||||
},
|
||||
'/swagger-ui': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
xfwd: true,
|
||||
},
|
||||
'/v1/api-docs': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
xfwd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './',
|
||||
|
||||
@@ -20,6 +20,8 @@ if errorlevel 1 (
|
||||
echo ✅ Java and jlink detected
|
||||
|
||||
echo ▶ Building Stirling-PDF JAR...
|
||||
|
||||
set DISABLE_ADDITIONAL_FEATURES=true
|
||||
call gradlew.bat clean bootJar --no-daemon
|
||||
if errorlevel 1 (
|
||||
echo ❌ Failed to build Stirling-PDF JAR
|
||||
|
||||
@@ -60,6 +60,7 @@ fi
|
||||
|
||||
# Clean and build the Stirling-PDF JAR
|
||||
print_step "Building Stirling-PDF JAR..."
|
||||
export DISABLE_ADDITIONAL_FEATURES=true
|
||||
./gradlew clean bootJar --no-daemon
|
||||
|
||||
if [ ! -f compgen -G "app/core/build/libs/stirling-pdf-*.jar" ]; then
|
||||
|
||||
Reference in New Issue
Block a user