mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b173a030e | ||
|
|
c5525d8676 | ||
|
|
c3456adc2b | ||
|
|
179b569769 | ||
|
|
341adaa07d | ||
|
|
feebfe82fa | ||
|
|
1e72416d55 | ||
|
|
959d14f075 | ||
|
|
8f6fcee428 | ||
|
|
651f17f1c6 | ||
|
|
fde449e738 | ||
|
|
85e9121745 | ||
|
|
12f1fd485e | ||
|
|
b49e8a2355 | ||
|
|
d908bc6785 | ||
|
|
4ae79d92ae | ||
|
|
85d9b5b83d | ||
|
|
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 {}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Interface for personal signature access (proprietary feature). Implemented only in proprietary
|
||||
* module to provide authenticated users access to their personal signatures.
|
||||
*/
|
||||
public interface PersonalSignatureServiceInterface {
|
||||
|
||||
/**
|
||||
* Get a personal signature from the user's folder. Only checks personal folder, not shared
|
||||
* folder.
|
||||
*
|
||||
* @param username Username of the signature owner
|
||||
* @param fileName Signature filename
|
||||
* @return Personal signature image bytes
|
||||
* @throws IOException If file not found or read error
|
||||
*/
|
||||
byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -51,7 +51,8 @@ public class RequestUriUtilsTest {
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute() {
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
|
||||
"React routes without extensions should be frontend routes");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.Dependency;
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.service.SignatureService;
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.annotations.api.UiDataApi;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
@@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils;
|
||||
public class UIDataController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final SignatureService signatureService;
|
||||
private final SharedSignatureService signatureService;
|
||||
private final UserServiceInterface userService;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
public UIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
SignatureService signatureService,
|
||||
SharedSignatureService signatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
ResourceLoader resourceLoader,
|
||||
RuntimePathConfig runtimePathConfig) {
|
||||
@@ -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;
|
||||
|
||||
-2
@@ -31,12 +31,10 @@ import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.security.config.PremiumEndpoint;
|
||||
|
||||
@Slf4j
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
@PremiumEndpoint
|
||||
public class ConvertPdfJsonController {
|
||||
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
+3
-3
@@ -25,7 +25,7 @@ import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.service.SignatureService;
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
@@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils;
|
||||
@Slf4j
|
||||
public class GeneralWebController {
|
||||
|
||||
private final SignatureService signatureService;
|
||||
private final SharedSignatureService signatureService;
|
||||
private final UserServiceInterface userService;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
public GeneralWebController(
|
||||
SignatureService signatureService,
|
||||
SharedSignatureService signatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
ResourceLoader resourceLoader,
|
||||
RuntimePathConfig runtimePathConfig) {
|
||||
|
||||
+4
-2
@@ -6,12 +6,14 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
|
||||
@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|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public String forwardNestedPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import stirling.software.SPDF.service.SignatureService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
|
||||
@RequestMapping("/api/v1/general")
|
||||
public class SignatureController {
|
||||
|
||||
private final SignatureService signatureService;
|
||||
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
public SignatureController(
|
||||
SignatureService signatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.signatureService = signatureService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping("/sign/{fileName}")
|
||||
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName)
|
||||
throws IOException {
|
||||
String username = "NON_SECURITY_USER";
|
||||
if (userService != null) {
|
||||
username = userService.getCurrentUsername();
|
||||
}
|
||||
// Verify access permission
|
||||
if (!signatureService.hasAccessToFile(username, fileName)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
byte[] imageBytes = signatureService.getSignatureBytes(username, fileName);
|
||||
return ResponseEntity.ok()
|
||||
.contentType( // Adjust based on file type
|
||||
MediaType.IMAGE_JPEG)
|
||||
.body(imageBytes);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.service.PersonalSignatureServiceInterface;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
/**
|
||||
* Unified signature image controller that works for both authenticated and unauthenticated users.
|
||||
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
|
||||
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
|
||||
* authenticated signature management (save/delete), see proprietary SignatureController.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
public class SignatureImageController {
|
||||
|
||||
private final SharedSignatureService sharedSignatureService;
|
||||
private final PersonalSignatureServiceInterface personalSignatureService;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
public SignatureImageController(
|
||||
SharedSignatureService sharedSignatureService,
|
||||
@Autowired(required = false) PersonalSignatureServiceInterface personalSignatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.sharedSignatureService = sharedSignatureService;
|
||||
this.personalSignatureService = personalSignatureService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signature image (works for both authenticated and unauthenticated users). -
|
||||
* Authenticated with proprietary: tries personal first, then shared - Unauthenticated or
|
||||
* community: tries shared only
|
||||
*/
|
||||
@GetMapping("/signatures/{fileName}")
|
||||
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName) {
|
||||
try {
|
||||
byte[] imageBytes = null;
|
||||
|
||||
// If proprietary service available and user authenticated, try personal folder first
|
||||
if (personalSignatureService != null && userService != null) {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
imageBytes =
|
||||
personalSignatureService.getPersonalSignatureBytes(username, fileName);
|
||||
} catch (Exception e) {
|
||||
// Not found in personal folder or not authenticated, will try shared
|
||||
log.debug("Personal signature not found, trying shared: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in personal (or no personal service), try shared
|
||||
if (imageBytes == null) {
|
||||
imageBytes = sharedSignatureService.getSharedSignatureBytes(fileName);
|
||||
}
|
||||
|
||||
// Determine content type from file extension
|
||||
MediaType contentType = MediaType.IMAGE_PNG; // Default
|
||||
String lowerFileName = fileName.toLowerCase();
|
||||
if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
|
||||
contentType = MediaType.IMAGE_JPEG;
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().contentType(contentType).body(imageBytes);
|
||||
} catch (IOException e) {
|
||||
log.debug("Signature not found: {}", fileName);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.SPDF.model.api.signature;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class SavedSignatureRequest {
|
||||
private String id;
|
||||
private String label;
|
||||
private String type; // "canvas", "image", "text"
|
||||
private String scope; // "personal", "shared"
|
||||
private String dataUrl; // For canvas and image types
|
||||
private String signerName; // For text type
|
||||
private String fontFamily; // For text type
|
||||
private Integer fontSize; // For text type
|
||||
private String textColor; // For text type
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.SPDF.model.api.signature;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SavedSignatureResponse {
|
||||
private String id;
|
||||
private String label;
|
||||
private String type; // "canvas", "image", "text"
|
||||
private String scope; // "personal", "shared"
|
||||
private String dataUrl; // For canvas and image types (or URL to fetch image)
|
||||
private String signerName; // For text type
|
||||
private String fontFamily; // For text type
|
||||
private Integer fontSize; // For text type
|
||||
private String textColor; // For text type
|
||||
private Long createdAt;
|
||||
private Long updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.model.api.signature.SavedSignatureRequest;
|
||||
import stirling.software.SPDF.model.api.signature.SavedSignatureResponse;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SharedSignatureService {
|
||||
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SharedSignatureService() {
|
||||
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public boolean hasAccessToFile(String username, String fileName) throws IOException {
|
||||
validateFileName(fileName);
|
||||
// Check if file exists in user's personal folder or ALL_USERS folder
|
||||
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
|
||||
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
|
||||
|
||||
return Files.exists(userPath) || Files.exists(allUsersPath);
|
||||
}
|
||||
|
||||
public List<SignatureFile> getAvailableSignatures(String username) {
|
||||
List<SignatureFile> signatures = new ArrayList<>();
|
||||
|
||||
// Get signatures from user's personal folder
|
||||
if (StringUtils.hasText(username)) {
|
||||
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
if (Files.exists(userFolder)) {
|
||||
try {
|
||||
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading user signatures folder", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get signatures from ALL_USERS folder
|
||||
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
if (Files.exists(allUsersFolder)) {
|
||||
try {
|
||||
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading shared signatures folder", e);
|
||||
}
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
|
||||
throws IOException {
|
||||
try (Stream<Path> stream = Files.list(folder)) {
|
||||
return stream.filter(this::isImageFile)
|
||||
.map(path -> new SignatureFile(path.getFileName().toString(), category))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signature from the shared (ALL_USERS) folder. This is always available for both
|
||||
* authenticated and unauthenticated users.
|
||||
*/
|
||||
public byte[] getSharedSignatureBytes(String fileName) throws IOException {
|
||||
validateFileName(fileName);
|
||||
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
|
||||
if (!Files.exists(allUsersPath)) {
|
||||
throw new FileNotFoundException("Shared signature file not found");
|
||||
}
|
||||
return Files.readAllBytes(allUsersPath);
|
||||
}
|
||||
|
||||
private boolean isImageFile(Path path) {
|
||||
String fileName = path.getFileName().toString().toLowerCase();
|
||||
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
|
||||
}
|
||||
|
||||
private void validateFileName(String fileName) {
|
||||
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
|
||||
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||
}
|
||||
}
|
||||
|
||||
private String validateAndNormalizeExtension(String extension) {
|
||||
String normalized = extension.toLowerCase().trim();
|
||||
// Whitelist only safe image extensions
|
||||
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
|
||||
return normalized;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported image extension: " + extension);
|
||||
}
|
||||
|
||||
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
|
||||
throws IOException {
|
||||
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
|
||||
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
|
||||
if (!canonicalResolved.startsWith(canonicalTarget)) {
|
||||
throw new IOException("Resolved path is outside the target directory");
|
||||
}
|
||||
}
|
||||
|
||||
/** Save a signature as image file */
|
||||
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
|
||||
throws IOException {
|
||||
validateFileName(request.getId());
|
||||
|
||||
// Determine folder based on scope
|
||||
String scope = request.getScope();
|
||||
if (scope == null || scope.isEmpty()) {
|
||||
scope = "personal"; // Default to personal
|
||||
}
|
||||
|
||||
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
|
||||
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
|
||||
Files.createDirectories(targetFolder);
|
||||
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
SavedSignatureResponse response = new SavedSignatureResponse();
|
||||
response.setId(request.getId());
|
||||
response.setLabel(request.getLabel());
|
||||
response.setType(request.getType());
|
||||
response.setScope(scope);
|
||||
response.setCreatedAt(timestamp);
|
||||
response.setUpdatedAt(timestamp);
|
||||
|
||||
// Extract and save image data
|
||||
String dataUrl = request.getDataUrl();
|
||||
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
|
||||
// Extract base64 data
|
||||
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
|
||||
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
|
||||
|
||||
// Determine and validate file extension from data URL
|
||||
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
|
||||
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
|
||||
String extension = validateAndNormalizeExtension(rawExtension);
|
||||
|
||||
// Save image file only
|
||||
String imageFileName = request.getId() + "." + extension;
|
||||
Path imagePath = targetFolder.resolve(imageFileName);
|
||||
|
||||
// Verify path is within target directory
|
||||
verifyPathWithinDirectory(imagePath, targetFolder);
|
||||
|
||||
Files.write(
|
||||
imagePath,
|
||||
imageBytes,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
// Store reference to image file
|
||||
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
|
||||
}
|
||||
|
||||
log.info("Saved signature {} for user {}", request.getId(), username);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Get all saved signatures for a user */
|
||||
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
|
||||
List<SavedSignatureResponse> signatures = new ArrayList<>();
|
||||
|
||||
// Load personal signatures
|
||||
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
if (Files.exists(personalFolder)) {
|
||||
try (Stream<Path> stream = Files.list(personalFolder)) {
|
||||
stream.filter(this::isImageFile)
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
String fileName = path.getFileName().toString();
|
||||
String id =
|
||||
fileName.substring(0, fileName.lastIndexOf('.'));
|
||||
|
||||
SavedSignatureResponse sig = new SavedSignatureResponse();
|
||||
sig.setId(id);
|
||||
sig.setLabel(id); // Use ID as label
|
||||
sig.setType("image"); // Default type
|
||||
sig.setScope("personal");
|
||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
|
||||
signatures.add(sig);
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading signature file: " + path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load shared signatures
|
||||
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
if (Files.exists(sharedFolder)) {
|
||||
try (Stream<Path> stream = Files.list(sharedFolder)) {
|
||||
stream.filter(this::isImageFile)
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
String fileName = path.getFileName().toString();
|
||||
String id =
|
||||
fileName.substring(0, fileName.lastIndexOf('.'));
|
||||
|
||||
SavedSignatureResponse sig = new SavedSignatureResponse();
|
||||
sig.setId(id);
|
||||
sig.setLabel(id); // Use ID as label
|
||||
sig.setType("image"); // Default type
|
||||
sig.setScope("shared");
|
||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
|
||||
signatures.add(sig);
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading signature file: " + path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
/** Delete a saved signature */
|
||||
public void deleteSignature(String username, String signatureId) throws IOException {
|
||||
validateFileName(signatureId);
|
||||
|
||||
// Try to find and delete image file in personal folder
|
||||
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
boolean deleted = false;
|
||||
|
||||
if (Files.exists(personalFolder)) {
|
||||
try (Stream<Path> stream = Files.list(personalFolder)) {
|
||||
List<Path> matchingFiles =
|
||||
stream.filter(
|
||||
path ->
|
||||
path.getFileName()
|
||||
.toString()
|
||||
.startsWith(signatureId + "."))
|
||||
.toList();
|
||||
for (Path file : matchingFiles) {
|
||||
Files.delete(file);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try shared folder if not found in personal
|
||||
if (!deleted) {
|
||||
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
if (Files.exists(sharedFolder)) {
|
||||
try (Stream<Path> stream = Files.list(sharedFolder)) {
|
||||
List<Path> matchingFiles =
|
||||
stream.filter(
|
||||
path ->
|
||||
path.getFileName()
|
||||
.toString()
|
||||
.startsWith(signatureId + "."))
|
||||
.toList();
|
||||
for (Path file : matchingFiles) {
|
||||
Files.delete(file);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new FileNotFoundException("Signature not found");
|
||||
}
|
||||
|
||||
log.info("Deleted signature {} for user {}", signatureId, username);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SignatureService {
|
||||
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
|
||||
public SignatureService() {
|
||||
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
|
||||
}
|
||||
|
||||
public boolean hasAccessToFile(String username, String fileName) throws IOException {
|
||||
validateFileName(fileName);
|
||||
// Check if file exists in user's personal folder or ALL_USERS folder
|
||||
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
|
||||
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
|
||||
|
||||
return Files.exists(userPath) || Files.exists(allUsersPath);
|
||||
}
|
||||
|
||||
public List<SignatureFile> getAvailableSignatures(String username) {
|
||||
List<SignatureFile> signatures = new ArrayList<>();
|
||||
|
||||
// Get signatures from user's personal folder
|
||||
if (StringUtils.hasText(username)) {
|
||||
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
if (Files.exists(userFolder)) {
|
||||
try {
|
||||
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading user signatures folder", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get signatures from ALL_USERS folder
|
||||
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
if (Files.exists(allUsersFolder)) {
|
||||
try {
|
||||
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading shared signatures folder", e);
|
||||
}
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
|
||||
throws IOException {
|
||||
try (Stream<Path> stream = Files.list(folder)) {
|
||||
return stream.filter(this::isImageFile)
|
||||
.map(path -> new SignatureFile(path.getFileName().toString(), category))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getSignatureBytes(String username, String fileName) throws IOException {
|
||||
validateFileName(fileName);
|
||||
// First try user's personal folder
|
||||
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
|
||||
if (Files.exists(userPath)) {
|
||||
return Files.readAllBytes(userPath);
|
||||
}
|
||||
|
||||
// Then try ALL_USERS folder
|
||||
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
|
||||
if (Files.exists(allUsersPath)) {
|
||||
return Files.readAllBytes(allUsersPath);
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Signature file not found");
|
||||
}
|
||||
|
||||
private boolean isImageFile(Path path) {
|
||||
String fileName = path.getFileName().toString().toLowerCase();
|
||||
return fileName.endsWith(".jpg")
|
||||
|| fileName.endsWith(".jpeg")
|
||||
|| fileName.endsWith(".png")
|
||||
|| fileName.endsWith(".gif");
|
||||
}
|
||||
|
||||
private void validateFileName(String fileName) {
|
||||
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
|
||||
class SignatureServiceTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
private SignatureService signatureService;
|
||||
private SharedSignatureService signatureService;
|
||||
private Path personalSignatureFolder;
|
||||
private Path sharedSignatureFolder;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
@@ -53,7 +53,7 @@ class SignatureServiceTest {
|
||||
.thenReturn(tempDir.toString());
|
||||
|
||||
// Initialize the service with our temp directory
|
||||
signatureService = new SignatureService();
|
||||
signatureService = new SharedSignatureService();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class SignatureServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSignatureBytes_PersonalFile() throws IOException {
|
||||
void testGetSharedSignatureBytes_SharedFile() throws IOException {
|
||||
// Mock static method for each test
|
||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
@@ -173,28 +173,8 @@ class SignatureServiceTest {
|
||||
.when(InstallationPathConfig::getSignaturesPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
|
||||
// Test
|
||||
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "personal.png");
|
||||
|
||||
// Verify
|
||||
assertEquals(
|
||||
"personal signature content",
|
||||
new String(bytes),
|
||||
"Should return the correct content for personal file");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSignatureBytes_SharedFile() throws IOException {
|
||||
// Mock static method for each test
|
||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mockedConfig
|
||||
.when(InstallationPathConfig::getSignaturesPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
|
||||
// Test
|
||||
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "shared.jpg");
|
||||
// Test - core service only reads shared signatures
|
||||
byte[] bytes = signatureService.getSharedSignatureBytes("shared.jpg");
|
||||
|
||||
// Verify
|
||||
assertEquals(
|
||||
@@ -205,7 +185,7 @@ class SignatureServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSignatureBytes_FileNotFound() {
|
||||
void testGetSharedSignatureBytes_FileNotFound() {
|
||||
// Mock static method for each test
|
||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
@@ -216,13 +196,13 @@ class SignatureServiceTest {
|
||||
// Test and verify
|
||||
assertThrows(
|
||||
FileNotFoundException.class,
|
||||
() -> signatureService.getSignatureBytes(TEST_USER, "nonexistent.png"),
|
||||
() -> signatureService.getSharedSignatureBytes("nonexistent.png"),
|
||||
"Should throw exception for non-existent files");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSignatureBytes_InvalidFileName() {
|
||||
void testGetSharedSignatureBytes_InvalidFileName() {
|
||||
// Mock static method for each test
|
||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
@@ -233,11 +213,28 @@ class SignatureServiceTest {
|
||||
// Test and verify
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> signatureService.getSignatureBytes(TEST_USER, "../invalid.png"),
|
||||
() -> signatureService.getSharedSignatureBytes("../invalid.png"),
|
||||
"Should throw exception for file names with directory traversal");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSharedSignatureBytes_CannotAccessPersonalFiles() {
|
||||
// Mock static method for each test
|
||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mockedConfig
|
||||
.when(InstallationPathConfig::getSignaturesPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
|
||||
// Test and verify - core service should NOT be able to read personal files
|
||||
assertThrows(
|
||||
FileNotFoundException.class,
|
||||
() -> signatureService.getSharedSignatureBytes("personal.png"),
|
||||
"Core service should not have access to personal signatures");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAvailableSignatures_EmptyUsername() throws IOException {
|
||||
// Mock static method for each test
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
|
||||
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.SignatureService;
|
||||
|
||||
/**
|
||||
* Controller for managing user signatures in proprietary/authenticated mode only. Requires user
|
||||
* authentication and enforces per-user storage limits. All endpoints require authentication
|
||||
* via @PreAuthorize("isAuthenticated()").
|
||||
*/
|
||||
@UserApi
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/proprietary/signatures")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public class SignatureController {
|
||||
|
||||
private final SignatureService signatureService;
|
||||
private final UserService userService;
|
||||
|
||||
/**
|
||||
* Save a new signature for the authenticated user. Enforces storage limits and authentication
|
||||
* requirements.
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<SavedSignatureResponse> saveSignature(
|
||||
@RequestBody SavedSignatureRequest request) {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
|
||||
// Validate request
|
||||
if (request.getDataUrl() == null || request.getDataUrl().isEmpty()) {
|
||||
log.warn("User {} attempted to save signature without dataUrl", username);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
SavedSignatureResponse response = signatureService.saveSignature(username, request);
|
||||
log.info("User {} saved signature {}", username, request.getId());
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid signature save request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to save signature", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all signatures accessible to the authenticated user. Includes both personal and shared
|
||||
* signatures.
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<List<SavedSignatureResponse>> listSignatures() {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
List<SavedSignatureResponse> signatures = signatureService.getSavedSignatures(username);
|
||||
return ResponseEntity.ok(signatures);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to list signatures for user", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a signature owned by the authenticated user. Users can only delete their own personal
|
||||
* signatures, not shared ones.
|
||||
*/
|
||||
@DeleteMapping("/{signatureId}")
|
||||
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
signatureService.deleteSignature(username, signatureId);
|
||||
log.info("User {} deleted signature {}", username, signatureId);
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 {
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.model.api.signature;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class SavedSignatureRequest {
|
||||
private String id;
|
||||
private String label;
|
||||
private String type; // "canvas", "image", "text"
|
||||
private String scope; // "personal", "shared"
|
||||
private String dataUrl; // For canvas and image types
|
||||
private String signerName; // For text type
|
||||
private String fontFamily; // For text type
|
||||
private Integer fontSize; // For text type
|
||||
private String textColor; // For text type
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.model.api.signature;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SavedSignatureResponse {
|
||||
private String id;
|
||||
private String label;
|
||||
private String type; // "canvas", "image", "text"
|
||||
private String scope; // "personal", "shared"
|
||||
private String dataUrl; // For canvas and image types (or URL to fetch image)
|
||||
private String signerName; // For text type
|
||||
private String fontFamily; // For text type
|
||||
private Integer fontSize; // For text type
|
||||
private String textColor; // For text type
|
||||
private Long createdAt;
|
||||
private Long updatedAt;
|
||||
}
|
||||
+29
-4
@@ -35,15 +35,40 @@ public class MailConfig {
|
||||
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
|
||||
mailSender.setHost(mailProperties.getHost());
|
||||
mailSender.setPort(mailProperties.getPort());
|
||||
mailSender.setUsername(mailProperties.getUsername());
|
||||
mailSender.setPassword(mailProperties.getPassword());
|
||||
mailSender.setDefaultEncoding("UTF-8");
|
||||
|
||||
// Only set username and password if they are provided
|
||||
String username = mailProperties.getUsername();
|
||||
String password = mailProperties.getPassword();
|
||||
boolean hasCredentials =
|
||||
(username != null && !username.trim().isEmpty())
|
||||
|| (password != null && !password.trim().isEmpty());
|
||||
|
||||
if (username != null && !username.trim().isEmpty()) {
|
||||
mailSender.setUsername(username);
|
||||
log.info("SMTP username configured");
|
||||
} else {
|
||||
log.info("SMTP username not configured - using anonymous connection");
|
||||
}
|
||||
|
||||
if (password != null && !password.trim().isEmpty()) {
|
||||
mailSender.setPassword(password);
|
||||
log.info("SMTP password configured");
|
||||
} else {
|
||||
log.info("SMTP password not configured");
|
||||
}
|
||||
|
||||
// Retrieves the JavaMail properties to configure additional SMTP parameters
|
||||
Properties props = mailSender.getJavaMailProperties();
|
||||
|
||||
// Enables SMTP authentication
|
||||
props.put("mail.smtp.auth", "true");
|
||||
// Only enable SMTP authentication if credentials are provided
|
||||
if (hasCredentials) {
|
||||
props.put("mail.smtp.auth", "true");
|
||||
log.info("SMTP authentication enabled");
|
||||
} else {
|
||||
props.put("mail.smtp.auth", "false");
|
||||
log.info("SMTP authentication disabled - no credentials provided");
|
||||
}
|
||||
|
||||
// Enables STARTTLS to encrypt the connection if supported by the SMTP server
|
||||
props.put("mail.smtp.starttls.enable", "true");
|
||||
|
||||
+165
@@ -1,22 +1,32 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
|
||||
@@ -242,4 +252,159 @@ public class AdminLicenseController {
|
||||
.body(Map.of("error", "Failed to retrieve license information"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a license certificate file for offline activation. Accepts .lic or .cert files,
|
||||
* validates the certificate format, saves to configs directory, and activates the license.
|
||||
*
|
||||
* @param file The license certificate file to upload
|
||||
* @return Response with success status, license type, and file information
|
||||
*/
|
||||
@PostMapping(value = "/license-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Upload license certificate file",
|
||||
description =
|
||||
"Upload a license certificate file (.lic, .cert) for offline activation."
|
||||
+ " Validates the file format and activates the license.")
|
||||
public ResponseEntity<Map<String, Object>> uploadLicenseFile(
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
|
||||
// Validate file exists
|
||||
if (file == null || file.isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "File is empty"));
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null || filename.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "Invalid filename"));
|
||||
}
|
||||
// Prevent path traversal and enforce single filename component
|
||||
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Filename must not contain path separators or '..'"));
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
if (!isValidLicenseFile(filename)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Invalid file type. Expected .lic or .cert"));
|
||||
}
|
||||
|
||||
// Check file size (max 1MB for license files)
|
||||
if (file.getSize() > 1_048_576) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "File too large. Maximum 1MB allowed"));
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate certificate format by reading content
|
||||
byte[] fileBytes = file.getBytes();
|
||||
String content = new String(fileBytes, StandardCharsets.UTF_8);
|
||||
if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Invalid license certificate format"));
|
||||
}
|
||||
|
||||
// Get config directory and target path
|
||||
Path configPath = Paths.get(InstallationPathConfig.getConfigPath());
|
||||
Path targetPath = configPath.resolve(filename).normalize();
|
||||
// Prevent directory traversal: ensure targetPath is inside configPath
|
||||
if (!targetPath.startsWith(configPath.normalize().toAbsolutePath())) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "Invalid file path"));
|
||||
}
|
||||
|
||||
// Backup existing file if present
|
||||
if (Files.exists(targetPath)) {
|
||||
Path backupDir = configPath.resolve("backup");
|
||||
Files.createDirectories(backupDir);
|
||||
|
||||
String backupFilename = filename + ".bak." + System.currentTimeMillis();
|
||||
Path backupPath = backupDir.resolve(backupFilename);
|
||||
|
||||
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("Backed up existing license file to: {}", backupPath);
|
||||
}
|
||||
|
||||
// Write new license file
|
||||
Files.write(targetPath, fileBytes);
|
||||
log.info("License file saved to: {}", targetPath);
|
||||
|
||||
// assume premium enabled when setting license key
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
|
||||
// Update settings with file reference (relative path)
|
||||
String fileReference = "file:configs/" + filename;
|
||||
licenseKeyChecker.updateLicenseKey(fileReference);
|
||||
|
||||
// Get license status after activation
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("licenseType", license.name());
|
||||
response.put("filename", filename);
|
||||
response.put("filePath", "configs/" + filename);
|
||||
response.put("enabled", applicationProperties.getPremium().isEnabled());
|
||||
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
|
||||
response.put("message", "License file uploaded and activated");
|
||||
|
||||
log.info(
|
||||
"License file uploaded and activated: filename={}, type={}",
|
||||
filename,
|
||||
license.name());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to save license file", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Failed to save license file: " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to activate license from file", e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Failed to activate license: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the filename has a valid license file extension (.lic or .cert)
|
||||
*
|
||||
* @param filename The filename to validate
|
||||
* @return true if the filename ends with .lic or .cert (case-insensitive)
|
||||
*/
|
||||
private boolean isValidLicenseFile(String filename) {
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
String lower = filename.toLowerCase();
|
||||
return lower.endsWith(".lic") || lower.endsWith(".cert");
|
||||
}
|
||||
}
|
||||
|
||||
+38
-4
@@ -407,7 +407,8 @@ public class UserController {
|
||||
public ResponseEntity<?> inviteUsers(
|
||||
@RequestParam(name = "emails", required = true) String emails,
|
||||
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
|
||||
@RequestParam(name = "teamId", required = false) Long teamId)
|
||||
@RequestParam(name = "teamId", required = false) Long teamId,
|
||||
HttpServletRequest request)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
|
||||
// Check if email invites are enabled
|
||||
@@ -477,6 +478,9 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
// Build login URL
|
||||
String loginUrl = buildLoginUrl(request);
|
||||
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
StringBuilder errors = new StringBuilder();
|
||||
@@ -488,7 +492,7 @@ public class UserController {
|
||||
continue;
|
||||
}
|
||||
|
||||
InviteResult result = processEmailInvite(email, effectiveTeamId, role);
|
||||
InviteResult result = processEmailInvite(email, effectiveTeamId, role, loginUrl);
|
||||
if (result.isSuccess()) {
|
||||
successCount++;
|
||||
} else {
|
||||
@@ -687,15 +691,45 @@ public class UserController {
|
||||
return ResponseEntity.ok(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build the login URL from the application configuration or request.
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return The login URL
|
||||
*/
|
||||
private String buildLoginUrl(HttpServletRequest request) {
|
||||
String baseUrl;
|
||||
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
|
||||
// Use configured frontend URL (remove trailing slash if present)
|
||||
baseUrl =
|
||||
configuredFrontendUrl.endsWith("/")
|
||||
? configuredFrontendUrl.substring(0, configuredFrontendUrl.length() - 1)
|
||||
: configuredFrontendUrl;
|
||||
} else {
|
||||
// Fall back to backend URL from request
|
||||
baseUrl =
|
||||
request.getScheme()
|
||||
+ "://"
|
||||
+ request.getServerName()
|
||||
+ (request.getServerPort() != 80 && request.getServerPort() != 443
|
||||
? ":" + request.getServerPort()
|
||||
: "");
|
||||
}
|
||||
return baseUrl + "/login";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to process a single email invitation.
|
||||
*
|
||||
* @param email The email address to invite
|
||||
* @param teamId The team ID to assign the user to
|
||||
* @param role The role to assign to the user
|
||||
* @param loginUrl The URL to the login page
|
||||
* @return InviteResult containing success status and optional error message
|
||||
*/
|
||||
private InviteResult processEmailInvite(String email, Long teamId, String role) {
|
||||
private InviteResult processEmailInvite(
|
||||
String email, Long teamId, String role, String loginUrl) {
|
||||
try {
|
||||
// Validate email format (basic check)
|
||||
if (!email.contains("@") || !email.contains(".")) {
|
||||
@@ -715,7 +749,7 @@ public class UserController {
|
||||
|
||||
// Send invite email
|
||||
try {
|
||||
emailService.get().sendInviteEmail(email, email, temporaryPassword);
|
||||
emailService.get().sendInviteEmail(email, email, temporaryPassword, loginUrl);
|
||||
log.info("Sent invite email to: {}", email);
|
||||
return InviteResult.success();
|
||||
} catch (Exception emailEx) {
|
||||
|
||||
+13
@@ -56,6 +56,19 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
List<User> findAllSsoUsers();
|
||||
|
||||
/**
|
||||
* Finds SSO users who have never created a session (pending activation) and are not yet
|
||||
* grandfathered.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT u FROM User u "
|
||||
+ "LEFT JOIN SessionEntity s ON u.username = s.principalName "
|
||||
+ "WHERE (u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')) "
|
||||
+ "AND (u.oauthGrandfathered IS NULL OR u.oauthGrandfathered = false) "
|
||||
+ "AND s.sessionId IS NULL")
|
||||
List<User> findPendingSsoUsersWithoutSession();
|
||||
|
||||
/**
|
||||
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
|
||||
*/
|
||||
|
||||
+1
-5
@@ -105,22 +105,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Validating JWT token");
|
||||
jwtService.validateToken(jwtToken);
|
||||
log.debug("JWT token validated successfully");
|
||||
} catch (AuthenticationFailureException e) {
|
||||
log.warn("JWT validation failed: {}", e.getMessage());
|
||||
log.debug("JWT validation failed: {}", e.getMessage());
|
||||
handleAuthenticationFailure(request, response, e);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
|
||||
String tokenUsername = claims.get("sub").toString();
|
||||
log.debug("JWT token username: {}", tokenUsername);
|
||||
|
||||
try {
|
||||
authenticate(request, claims);
|
||||
log.debug("Authentication successful for user: {}", tokenUsername);
|
||||
} catch (SQLException | UnsupportedProviderException e) {
|
||||
log.error("Error processing user authentication for user: {}", tokenUsername, e);
|
||||
handleAuthenticationFailure(
|
||||
|
||||
+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) {
|
||||
|
||||
+12
-2
@@ -115,10 +115,12 @@ public class EmailService {
|
||||
* @param to The recipient email address
|
||||
* @param username The username for the new account
|
||||
* @param temporaryPassword The temporary password
|
||||
* @param loginUrl The URL to the login page
|
||||
* @throws MessagingException If there is an issue with creating or sending the email.
|
||||
*/
|
||||
@Async
|
||||
public void sendInviteEmail(String to, String username, String temporaryPassword)
|
||||
public void sendInviteEmail(
|
||||
String to, String username, String temporaryPassword, String loginUrl)
|
||||
throws MessagingException {
|
||||
String subject = "Welcome to Stirling PDF";
|
||||
|
||||
@@ -144,6 +146,14 @@ public class EmailService {
|
||||
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; color: #856404;"><strong>⚠️ Important:</strong> You will be required to change your password upon first login for security reasons.</p>
|
||||
</div>
|
||||
<!-- CTA Button -->
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Log In to Stirling PDF</a>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
|
||||
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
|
||||
%s
|
||||
</div>
|
||||
<p>Please keep these credentials secure and do not share them with anyone.</p>
|
||||
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
|
||||
</div>
|
||||
@@ -155,7 +165,7 @@ public class EmailService {
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
.formatted(username, temporaryPassword);
|
||||
.formatted(username, temporaryPassword, loginUrl, loginUrl);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
|
||||
+1
-5
@@ -50,7 +50,6 @@ public class JwtService implements JwtServiceInterface {
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
log.info("JwtService initialized");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -256,11 +255,9 @@ public class JwtService implements JwtServiceInterface {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
log.debug("JWT token extracted from Authorization header");
|
||||
return token;
|
||||
}
|
||||
|
||||
log.debug("No JWT token found in Authorization header");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -283,10 +280,9 @@ public class JwtService implements JwtServiceInterface {
|
||||
.parse(token)
|
||||
.getHeader()
|
||||
.get("kid");
|
||||
log.debug("Extracted key ID from token: {}", keyId);
|
||||
return keyId;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract key ID from token header: {}", e.getMessage());
|
||||
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -55,7 +55,6 @@ public class KeyPairCleanupService {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Removing keys older than retention period");
|
||||
removeKeys(eligibleKeys);
|
||||
keyPersistenceService.refreshActiveKeyPair();
|
||||
}
|
||||
|
||||
+26
@@ -778,4 +778,30 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grandfathers SSO users who have never created a session (invited/pending accounts). These
|
||||
* users would otherwise be blocked when SSO requires a paid license despite existing before the
|
||||
* policy change.
|
||||
*
|
||||
* @return Number of pending users updated
|
||||
*/
|
||||
@Transactional
|
||||
public int grandfatherPendingSsoUsersWithoutSession() {
|
||||
List<User> pendingUsers = userRepository.findPendingSsoUsersWithoutSession();
|
||||
int updated = 0;
|
||||
|
||||
for (User user : pendingUsers) {
|
||||
if (!user.isOauthGrandfathered()) {
|
||||
user.setOauthGrandfathered(true);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
userRepository.saveAll(pendingUsers);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.PersonalSignatureServiceInterface;
|
||||
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
|
||||
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||
|
||||
/**
|
||||
* Service for managing user signatures with authentication and storage limits. This proprietary
|
||||
* version enforces per-user quotas and requires authentication. Provides access to personal
|
||||
* signatures only (shared signatures handled by core service).
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
|
||||
// Storage limits per user
|
||||
private static final int MAX_SIGNATURES_PER_USER = 20;
|
||||
private static final long MAX_SIGNATURE_SIZE_BYTES = 2_000_000; // 2MB per signature
|
||||
private static final long MAX_TOTAL_USER_STORAGE_BYTES = 20_000_000; // 20MB total per user
|
||||
|
||||
public SignatureService() {
|
||||
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a personal signature from the user's folder only. Does NOT check shared folder (that's
|
||||
* handled by core service).
|
||||
*/
|
||||
@Override
|
||||
public byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException {
|
||||
validateFileName(fileName);
|
||||
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
|
||||
|
||||
if (!Files.exists(userPath)) {
|
||||
throw new FileNotFoundException("Personal signature not found");
|
||||
}
|
||||
|
||||
return Files.readAllBytes(userPath);
|
||||
}
|
||||
|
||||
/** Save a signature with storage limits enforced. */
|
||||
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
|
||||
throws IOException {
|
||||
validateFileName(request.getId());
|
||||
|
||||
// Determine folder based on scope
|
||||
String scope = request.getScope();
|
||||
if (scope == null || scope.isEmpty()) {
|
||||
scope = "personal"; // Default to personal
|
||||
}
|
||||
|
||||
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
|
||||
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
|
||||
|
||||
// Only enforce limits for personal signatures (not shared)
|
||||
if ("personal".equals(scope)) {
|
||||
enforceStorageLimits(username, request.getDataUrl());
|
||||
}
|
||||
|
||||
Files.createDirectories(targetFolder);
|
||||
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
SavedSignatureResponse response = new SavedSignatureResponse();
|
||||
response.setId(request.getId());
|
||||
response.setLabel(request.getLabel());
|
||||
response.setType(request.getType());
|
||||
response.setScope(scope);
|
||||
response.setCreatedAt(timestamp);
|
||||
response.setUpdatedAt(timestamp);
|
||||
|
||||
// Extract and save image data
|
||||
String dataUrl = request.getDataUrl();
|
||||
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
|
||||
// Validate dataUrl size before decoding
|
||||
if (dataUrl.length() > MAX_SIGNATURE_SIZE_BYTES * 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"Signature data too large (max "
|
||||
+ (MAX_SIGNATURE_SIZE_BYTES / 1024)
|
||||
+ "KB)");
|
||||
}
|
||||
|
||||
// Extract base64 data
|
||||
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
|
||||
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
|
||||
|
||||
// Validate decoded size
|
||||
if (imageBytes.length > MAX_SIGNATURE_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException(
|
||||
"Signature image too large (max "
|
||||
+ (MAX_SIGNATURE_SIZE_BYTES / 1024)
|
||||
+ "KB)");
|
||||
}
|
||||
|
||||
// Determine and validate file extension from data URL
|
||||
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
|
||||
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
|
||||
String extension = validateAndNormalizeExtension(rawExtension);
|
||||
|
||||
// Save image file
|
||||
String imageFileName = request.getId() + "." + extension;
|
||||
Path imagePath = targetFolder.resolve(imageFileName);
|
||||
|
||||
// Verify path is within target directory
|
||||
verifyPathWithinDirectory(imagePath, targetFolder);
|
||||
|
||||
Files.write(
|
||||
imagePath,
|
||||
imageBytes,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
// Store reference to image file (unified endpoint for all signatures)
|
||||
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
|
||||
}
|
||||
|
||||
log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Get all saved signatures for a user (personal + shared). */
|
||||
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
|
||||
List<SavedSignatureResponse> signatures = new ArrayList<>();
|
||||
|
||||
// Load personal signatures
|
||||
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
if (Files.exists(personalFolder)) {
|
||||
signatures.addAll(loadSignaturesFromFolder(personalFolder, "personal", true));
|
||||
}
|
||||
|
||||
// Load shared signatures
|
||||
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
if (Files.exists(sharedFolder)) {
|
||||
signatures.addAll(loadSignaturesFromFolder(sharedFolder, "shared", false));
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
/** Delete a signature from user's personal folder. Cannot delete shared signatures. */
|
||||
public void deleteSignature(String username, String signatureId) throws IOException {
|
||||
validateFileName(signatureId);
|
||||
|
||||
// Only allow deletion from personal folder
|
||||
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
boolean deleted = false;
|
||||
|
||||
if (Files.exists(personalFolder)) {
|
||||
try (Stream<Path> stream = Files.list(personalFolder)) {
|
||||
List<Path> matchingFiles =
|
||||
stream.filter(
|
||||
path ->
|
||||
path.getFileName()
|
||||
.toString()
|
||||
.startsWith(signatureId + "."))
|
||||
.toList();
|
||||
for (Path file : matchingFiles) {
|
||||
Files.delete(file);
|
||||
deleted = true;
|
||||
log.info("Deleted signature file: {}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new FileNotFoundException("Signature not found or cannot be deleted");
|
||||
}
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
|
||||
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
|
||||
if (!Files.exists(userFolder)) {
|
||||
return; // First signature, no limits to check
|
||||
}
|
||||
|
||||
// Count existing signatures
|
||||
long signatureCount;
|
||||
try (Stream<Path> stream = Files.list(userFolder)) {
|
||||
signatureCount = stream.filter(this::isImageFile).count();
|
||||
}
|
||||
|
||||
if (signatureCount >= MAX_SIGNATURES_PER_USER) {
|
||||
throw new IllegalArgumentException(
|
||||
"Maximum signatures limit reached (" + MAX_SIGNATURES_PER_USER + ")");
|
||||
}
|
||||
|
||||
// Calculate total storage used
|
||||
long totalSize = 0;
|
||||
try (Stream<Path> stream = Files.list(userFolder)) {
|
||||
totalSize =
|
||||
stream.filter(this::isImageFile)
|
||||
.mapToLong(
|
||||
path -> {
|
||||
try {
|
||||
return Files.size(path);
|
||||
} catch (IOException e) {
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
}
|
||||
|
||||
// Estimate new signature size (base64 decodes to ~75% of original)
|
||||
long estimatedNewSize = (long) (dataUrlToAdd.length() * 0.75);
|
||||
|
||||
if (totalSize + estimatedNewSize > MAX_TOTAL_USER_STORAGE_BYTES) {
|
||||
throw new IllegalArgumentException(
|
||||
"Storage quota exceeded (max "
|
||||
+ (MAX_TOTAL_USER_STORAGE_BYTES / 1_000_000)
|
||||
+ "MB)");
|
||||
}
|
||||
}
|
||||
|
||||
private List<SavedSignatureResponse> loadSignaturesFromFolder(
|
||||
Path folder, String scope, boolean isPersonal) throws IOException {
|
||||
List<SavedSignatureResponse> signatures = new ArrayList<>();
|
||||
|
||||
try (Stream<Path> stream = Files.list(folder)) {
|
||||
stream.filter(this::isImageFile)
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
String fileName = path.getFileName().toString();
|
||||
String id = fileName.substring(0, fileName.lastIndexOf('.'));
|
||||
|
||||
SavedSignatureResponse sig = new SavedSignatureResponse();
|
||||
sig.setId(id);
|
||||
sig.setLabel(id);
|
||||
sig.setType("image");
|
||||
sig.setScope(scope);
|
||||
sig.setCreatedAt(Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(Files.getLastModifiedTime(path).toMillis());
|
||||
|
||||
// Set unified URL path (works for both personal and shared)
|
||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||
|
||||
signatures.add(sig);
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading signature file: " + path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
private boolean isImageFile(Path path) {
|
||||
String fileName = path.getFileName().toString().toLowerCase();
|
||||
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
|
||||
}
|
||||
|
||||
private void validateFileName(String fileName) {
|
||||
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||
}
|
||||
}
|
||||
|
||||
private String validateAndNormalizeExtension(String extension) {
|
||||
String normalized = extension.toLowerCase().trim();
|
||||
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
|
||||
return normalized;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported image extension: " + extension);
|
||||
}
|
||||
|
||||
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
|
||||
throws IOException {
|
||||
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
|
||||
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
|
||||
if (!canonicalResolved.startsWith(canonicalTarget)) {
|
||||
throw new IOException("Resolved path is outside the target directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -192,10 +192,18 @@ public class UserLicenseSettingsService {
|
||||
+ "They will retain OAuth access even without a paid license. "
|
||||
+ "New users will require a paid license for OAuth.",
|
||||
updated);
|
||||
} else if (grandfatheredCount > 0) {
|
||||
log.debug(
|
||||
"OAuth grandfathering already completed: {} users grandfathered",
|
||||
grandfatheredCount);
|
||||
}
|
||||
|
||||
// Grandfather pending users (invited but never logged in)
|
||||
// The query filters to non-grandfathered users only, so this is idempotent
|
||||
if (grandfatheredCount > 0 || oauthUsersCount > 0) {
|
||||
int pendingUpdated = userService.grandfatherPendingSsoUsersWithoutSession();
|
||||
if (pendingUpdated > 0) {
|
||||
log.warn(
|
||||
"OAuth GRANDFATHERING: Marked {} pending SSO users (no prior sessions) as"
|
||||
+ " grandfathered.",
|
||||
pendingUpdated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+69
@@ -2,6 +2,9 @@ package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -198,4 +201,70 @@ class UserLicenseSettingsServiceTest {
|
||||
|
||||
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_runsOnlyWhenNoneGrandfathered() {
|
||||
// With grandfatheredCount == 0, should run grandfathering for all users
|
||||
when(userService.countOAuthUsers()).thenReturn(10L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
|
||||
when(userService.grandfatherAllOAuthUsers()).thenReturn(10);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, times(1)).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_skipsMainButRunsPendingWhenSomeAlreadyGrandfathered() {
|
||||
// V2→V2.1 upgrade: some users already grandfathered, but pending users need to be checked
|
||||
when(userService.countOAuthUsers()).thenReturn(10L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(4L);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(2);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, never()).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_stillChecksPendingWhenAllUsersGrandfathered() {
|
||||
// All active users grandfathered, but still check for pending users
|
||||
when(userService.countOAuthUsers()).thenReturn(10L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(10L);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, never()).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_skipsWhenNoOAuthUsers() {
|
||||
when(userService.countOAuthUsers()).thenReturn(0L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, never()).grandfatherAllOAuthUsers();
|
||||
verify(userService, never()).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_grandfathersPendingUsersOnFirstRun() {
|
||||
// Pending users (invited but never logged in) should be grandfathered
|
||||
// during the initial grandfathering run (when grandfatheredCount == 0)
|
||||
when(userService.countOAuthUsers()).thenReturn(5L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
|
||||
when(userService.grandfatherAllOAuthUsers()).thenReturn(5);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(3);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, times(1)).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.0.1'
|
||||
version = '2.0.2'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
Generated
+18
@@ -20,6 +20,7 @@
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
"@embedpdf/plugin-loader": "^1.4.1",
|
||||
"@embedpdf/plugin-pan": "^1.4.1",
|
||||
"@embedpdf/plugin-print": "^1.4.1",
|
||||
"@embedpdf/plugin-render": "^1.4.1",
|
||||
"@embedpdf/plugin-rotate": "^1.4.1",
|
||||
"@embedpdf/plugin-scroll": "^1.4.1",
|
||||
@@ -741,6 +742,23 @@
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-print": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-1.4.1.tgz",
|
||||
"integrity": "sha512-YEjU6rQVW8wb125JXl1wma95+JISwADZpfqZZOtvPBRABp6ce4byblDTNjWVmTYWSgKUZrlXCL3Ff3Ig+bjbjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0",
|
||||
"svelte": ">=5 <6",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
"@embedpdf/plugin-loader": "^1.4.1",
|
||||
"@embedpdf/plugin-pan": "^1.4.1",
|
||||
"@embedpdf/plugin-print": "^1.4.1",
|
||||
"@embedpdf/plugin-render": "^1.4.1",
|
||||
"@embedpdf/plugin-rotate": "^1.4.1",
|
||||
"@embedpdf/plugin-scroll": "^1.4.1",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2267,8 +2267,16 @@ defaultCanvasLabel = "Drawing signature"
|
||||
defaultImageLabel = "Uploaded signature"
|
||||
defaultTextLabel = "Typed signature"
|
||||
saveButton = "Save signature"
|
||||
savePersonal = "Save Personal"
|
||||
saveShared = "Save Shared"
|
||||
saveUnavailable = "Create a signature first to save it."
|
||||
noChanges = "Current signature is already saved."
|
||||
tempStorageTitle = "Temporary browser storage"
|
||||
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
|
||||
personalHeading = "Personal Signatures"
|
||||
sharedHeading = "Shared Signatures"
|
||||
personalDescription = "Only you can see these signatures."
|
||||
sharedDescription = "All users can see and use these signatures."
|
||||
|
||||
[sign.saved.type]
|
||||
canvas = "Drawing"
|
||||
@@ -3891,6 +3899,7 @@ toggleSidebar = "Toggle Sidebar"
|
||||
exportSelected = "Export Selected Pages"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
annotationMode = "Toggle Annotation Mode"
|
||||
print = "Print PDF"
|
||||
draw = "Draw"
|
||||
save = "Save"
|
||||
saveChanges = "Save Changes"
|
||||
@@ -4508,6 +4517,7 @@ description = "URL or filename to impressum (required in some jurisdictions)"
|
||||
title = "Premium & Enterprise"
|
||||
description = "Configure your premium or enterprise license key."
|
||||
license = "License Configuration"
|
||||
noInput = "Please provide a license key or file"
|
||||
|
||||
[admin.settings.premium.licenseKey]
|
||||
toggle = "Got a license key or certificate file?"
|
||||
@@ -4525,6 +4535,26 @@ line1 = "Overwriting your current license key cannot be undone."
|
||||
line2 = "Your previous license will be permanently lost unless you have backed it up elsewhere."
|
||||
line3 = "Important: Keep license keys private and secure. Never share them publicly."
|
||||
|
||||
[admin.settings.premium.inputMethod]
|
||||
text = "License Key"
|
||||
file = "Certificate File"
|
||||
|
||||
[admin.settings.premium.file]
|
||||
label = "License Certificate File"
|
||||
description = "Upload your .lic or .cert license file from offline purchases"
|
||||
choose = "Choose License File"
|
||||
selected = "Selected: {{filename}} ({{size}})"
|
||||
successMessage = "License file uploaded and activated successfully. No restart required."
|
||||
|
||||
[admin.settings.premium.currentLicense]
|
||||
title = "Active License"
|
||||
file = "Source: License file ({{path}})"
|
||||
key = "Source: License key"
|
||||
type = "Type: {{type}}"
|
||||
|
||||
noInput = "Please provide a license key or upload a certificate file"
|
||||
success = "Success"
|
||||
|
||||
[admin.settings.premium.enabled]
|
||||
label = "Enable Premium Features"
|
||||
description = "Enable license key checks for pro/enterprise features"
|
||||
@@ -5261,7 +5291,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
|
||||
emailsRequired = "At least one email address is required"
|
||||
submit = "Send Invites"
|
||||
success = "user(s) invited successfully"
|
||||
partialSuccess = "Some invites failed"
|
||||
partialFailure = "Some invites failed"
|
||||
allFailed = "Failed to invite users"
|
||||
error = "Failed to send invites"
|
||||
|
||||
@@ -5777,7 +5807,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 +5843,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"
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ Version=1.0
|
||||
Type=Application
|
||||
Name=Stirling-PDF
|
||||
Comment=Locally hosted web application that allows you to perform various operations on PDF files
|
||||
Exec=/usr/bin/stirling-pdf
|
||||
Icon={{icon}}
|
||||
Terminal=false
|
||||
MimeType=application/pdf;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
@@ -19,7 +19,7 @@ export default function App() {
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</Suspense>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
@@ -77,7 +76,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<BannerProvider>
|
||||
<OnboardingProvider>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
@@ -113,7 +111,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
</BannerProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
|
||||
export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin';
|
||||
|
||||
export interface FlowConfig {
|
||||
type: FlowType;
|
||||
ids: SlideId[];
|
||||
}
|
||||
|
||||
export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig {
|
||||
if (!enableLogin) {
|
||||
return selfReportedAdmin
|
||||
? {
|
||||
type: 'no-login-admin',
|
||||
ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin],
|
||||
}
|
||||
: {
|
||||
type: 'no-login',
|
||||
ids: FLOW_SEQUENCES.noLoginBase,
|
||||
};
|
||||
}
|
||||
|
||||
return isAdmin
|
||||
? {
|
||||
type: 'login-admin',
|
||||
ids: FLOW_SEQUENCES.loginAdmin,
|
||||
}
|
||||
: {
|
||||
type: 'login-user',
|
||||
ids: FLOW_SEQUENCES.loginUser,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onb
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
|
||||
interface RenderButtonsProps {
|
||||
interface SlideButtonsProps {
|
||||
slideDefinition: {
|
||||
buttons: ButtonDefinition[];
|
||||
id: string;
|
||||
@@ -16,7 +16,7 @@ interface RenderButtonsProps {
|
||||
onAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
|
||||
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
|
||||
@@ -105,5 +105,4 @@ export function renderButtons({ slideDefinition, licenseNotice, flowState, onAct
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
|
||||
export interface InitialOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
||||
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
step: number;
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
selfReportedAdmin: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATE: OnboardingState = {
|
||||
step: 0,
|
||||
selectedRole: null,
|
||||
selfReportedAdmin: false,
|
||||
};
|
||||
|
||||
-381
@@ -1,381 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useOs } from '@app/hooks/useOs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type ButtonAction,
|
||||
type FlowState,
|
||||
type SlideId,
|
||||
} from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
|
||||
import { DOWNLOAD_URLS } from '@app/constants/downloads';
|
||||
|
||||
interface UseInitialOnboardingStateResult {
|
||||
state: OnboardingState;
|
||||
totalSteps: number;
|
||||
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
|
||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
|
||||
licenseNotice: LicenseNotice;
|
||||
flowState: FlowState;
|
||||
closeAndMarkSeen: () => void;
|
||||
handleButtonAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function useInitialOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
onRequestServerLicense,
|
||||
onLicenseNoticeUpdate,
|
||||
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { startTour } = useOnboarding();
|
||||
const {
|
||||
loginEnabled: loginEnabledFromServer,
|
||||
configIsAdmin,
|
||||
totalUsers: serverTotalUsers,
|
||||
userCountResolved: serverUserCountResolved,
|
||||
freeTierLimit,
|
||||
hasPaidLicense,
|
||||
scenarioKey,
|
||||
setSelfReportedAdmin,
|
||||
isNewServer,
|
||||
} = useServerExperience();
|
||||
const osType = useOs();
|
||||
const navigate = useNavigate();
|
||||
const selectedDownloadUrlRef = useRef<string>('');
|
||||
|
||||
const [state, setState] = useState<OnboardingState>(DEFAULT_STATE);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setState(DEFAULT_STATE);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
resetState();
|
||||
}
|
||||
}, [opened, resetState]);
|
||||
|
||||
const handleRoleSelect = useCallback(
|
||||
(role: 'admin' | 'user' | null) => {
|
||||
const isAdminSelection = role === 'admin';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedRole: role,
|
||||
selfReportedAdmin: isAdminSelection,
|
||||
}));
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (isAdminSelection) {
|
||||
window.localStorage.setItem('stirling-self-reported-admin', 'true');
|
||||
} else {
|
||||
window.localStorage.removeItem('stirling-self-reported-admin');
|
||||
}
|
||||
}
|
||||
|
||||
setSelfReportedAdmin(isAdminSelection);
|
||||
},
|
||||
[setSelfReportedAdmin],
|
||||
);
|
||||
|
||||
const closeAndMarkSeen = useCallback(() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
|
||||
|
||||
const isAdmin = configIsAdmin;
|
||||
const enableLogin = loginEnabledFromServer;
|
||||
|
||||
const effectiveEnableLogin = enableLogin;
|
||||
const effectiveIsAdmin = isAdmin;
|
||||
const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
|
||||
handleRoleSelect('admin');
|
||||
}
|
||||
}, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
|
||||
|
||||
const shouldUseServerCount =
|
||||
(effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
|
||||
const licenseUserCountFromServer =
|
||||
shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
|
||||
|
||||
const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
|
||||
|
||||
const os = useMemo(() => {
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
|
||||
case 'mac-apple':
|
||||
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
case 'mac-intel':
|
||||
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case 'linux-x64':
|
||||
case 'linux-arm64':
|
||||
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
default:
|
||||
return { label: '', url: '' };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
|
||||
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
|
||||
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
|
||||
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
|
||||
];
|
||||
return options.filter(opt => opt.url);
|
||||
}, []);
|
||||
|
||||
const resolvedFlow = useMemo(
|
||||
() => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
|
||||
[effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
|
||||
);
|
||||
const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer;
|
||||
const flowSlideIds = useMemo(
|
||||
() =>
|
||||
shouldSkipSecurityCheck
|
||||
? resolvedFlow.ids.filter((id) => id !== 'security-check')
|
||||
: resolvedFlow.ids,
|
||||
[resolvedFlow.ids, shouldSkipSecurityCheck],
|
||||
);
|
||||
const flowType = resolvedFlow.type;
|
||||
const totalSteps = flowSlideIds.length;
|
||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.step >= flowSlideIds.length) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.max(flowSlideIds.length - 1, 0),
|
||||
}));
|
||||
}
|
||||
}, [flowSlideIds.length, state.step]);
|
||||
|
||||
const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
||||
|
||||
if (!slideDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scenarioProvidesInfo =
|
||||
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
|
||||
const scenarioIndicatesAdmin = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('admin')
|
||||
: state.selfReportedAdmin || effectiveIsAdmin;
|
||||
const scenarioIndicatesOverLimit = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('over-limit')
|
||||
: effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
|
||||
const scenarioRequiresLicense =
|
||||
scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
|
||||
|
||||
const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
|
||||
|
||||
const licenseNotice = useMemo<LicenseNotice>(
|
||||
() => ({
|
||||
totalUsers: effectiveLicenseUserCount,
|
||||
freeTierLimit,
|
||||
isOverLimit: scenarioIndicatesOverLimit,
|
||||
requiresLicense: shouldShowServerLicenseInfo,
|
||||
}),
|
||||
[
|
||||
effectiveLicenseUserCount,
|
||||
freeTierLimit,
|
||||
scenarioIndicatesOverLimit,
|
||||
shouldShowServerLicenseInfo,
|
||||
],
|
||||
);
|
||||
|
||||
const requestServerLicenseIfNeeded = useCallback(
|
||||
(options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
|
||||
if (!shouldShowServerLicenseInfo) {
|
||||
return;
|
||||
}
|
||||
onRequestServerLicense?.(options);
|
||||
},
|
||||
[onRequestServerLicense, shouldShowServerLicenseInfo],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLicenseNoticeUpdate?.(licenseNotice);
|
||||
}, [licenseNotice, onLicenseNoticeUpdate]);
|
||||
|
||||
// Initialize ref with default URL
|
||||
useEffect(() => {
|
||||
if (!selectedDownloadUrlRef.current && os.url) {
|
||||
selectedDownloadUrlRef.current = os.url;
|
||||
}
|
||||
}, [os.url]);
|
||||
|
||||
const handleDownloadUrlChange = useCallback((url: string) => {
|
||||
selectedDownloadUrlRef.current = url;
|
||||
}, []);
|
||||
|
||||
const currentSlide = slideDefinition.createSlide({
|
||||
osLabel: os.label,
|
||||
osUrl: os.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
selectedRole: state.selectedRole,
|
||||
onRoleSelect: handleRoleSelect,
|
||||
licenseNotice,
|
||||
loginEnabled: effectiveEnableLogin,
|
||||
});
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.min(prev.step + 1, maxIndex),
|
||||
}));
|
||||
}, [maxIndex]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.max(prev.step - 1, 0),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const launchTour = useCallback(
|
||||
(mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
|
||||
if (options?.closeOnboardingSlides) {
|
||||
closeAndMarkSeen();
|
||||
}
|
||||
|
||||
startTour(mode, {
|
||||
source: 'initial-onboarding-modal',
|
||||
metadata: {
|
||||
hasCompletedOnboarding: preferences.hasCompletedOnboarding,
|
||||
toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
|
||||
selfReportedAdmin: state.selfReportedAdmin,
|
||||
},
|
||||
});
|
||||
},
|
||||
[closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
|
||||
);
|
||||
|
||||
const handleButtonAction = useCallback(
|
||||
(action: ButtonAction) => {
|
||||
const currentSlideIdLocal = currentSlideId;
|
||||
const shouldAutoLaunchLoginUserTour =
|
||||
flowType === 'login-user' && currentSlideIdLocal === 'desktop-install';
|
||||
|
||||
switch (action) {
|
||||
case 'next':
|
||||
if (shouldAutoLaunchLoginUserTour) {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
goNext();
|
||||
return;
|
||||
case 'prev':
|
||||
goPrev();
|
||||
return;
|
||||
case 'close':
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'download-selected': {
|
||||
const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank', 'noopener');
|
||||
}
|
||||
if (shouldAutoLaunchLoginUserTour) {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
goNext();
|
||||
return;
|
||||
}
|
||||
case 'complete-close':
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'security-next':
|
||||
if (!state.selectedRole) {
|
||||
return;
|
||||
}
|
||||
if (state.selectedRole === 'admin') {
|
||||
goNext();
|
||||
} else {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
}
|
||||
return;
|
||||
case 'launch-admin':
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: true,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
launchTour('admin', { closeOnboardingSlides: true });
|
||||
return;
|
||||
case 'launch-tools':
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
case 'launch-auto': {
|
||||
const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
|
||||
if (launchMode === 'admin') {
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: true,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
}
|
||||
launchTour(launchMode, { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: false,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'see-plans':
|
||||
closeAndMarkSeen();
|
||||
navigate('/settings/adminPlan');
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
closeAndMarkSeen,
|
||||
currentSlide,
|
||||
effectiveIsAdmin,
|
||||
flowType,
|
||||
goNext,
|
||||
goPrev,
|
||||
launchTour,
|
||||
navigate,
|
||||
requestServerLicenseIfNeeded,
|
||||
onRequestServerLicense,
|
||||
os.url,
|
||||
state.selectedRole,
|
||||
state.selfReportedAdmin,
|
||||
updatePreference,
|
||||
],
|
||||
);
|
||||
|
||||
const flowState: FlowState = { selectedRole: state.selectedRole };
|
||||
|
||||
return {
|
||||
state,
|
||||
totalSteps,
|
||||
slideDefinition,
|
||||
currentSlide,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { type StepType } from '@reactour/tour';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { isAuthRoute } from '@app/constants/routes';
|
||||
import { dispatchTourState } from '@app/constants/events';
|
||||
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
|
||||
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
|
||||
import {
|
||||
useServerLicenseRequest,
|
||||
useTourRequest,
|
||||
} from '@app/components/onboarding/useOnboardingEffects';
|
||||
import { useOnboardingDownload } from '@app/components/onboarding/useOnboardingDownload';
|
||||
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
|
||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
|
||||
export default function Onboarding() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { state, actions } = useOnboardingOrchestrator();
|
||||
const serverExperience = useServerExperience();
|
||||
const onAuthRoute = isAuthRoute(location.pathname);
|
||||
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
|
||||
|
||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
||||
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
||||
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
||||
|
||||
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
|
||||
actions.updateRuntimeState({ selectedRole: role });
|
||||
serverExperience.setSelfReportedAdmin(role === 'admin');
|
||||
}, [actions, serverExperience]);
|
||||
|
||||
const handlePasswordChanged = useCallback(() => {
|
||||
actions.updateRuntimeState({ requiresPasswordChange: false });
|
||||
window.location.href = '/login';
|
||||
}, [actions]);
|
||||
|
||||
const handleButtonAction = useCallback((action: ButtonAction) => {
|
||||
switch (action) {
|
||||
case 'next':
|
||||
case 'complete-close':
|
||||
actions.complete();
|
||||
break;
|
||||
case 'prev':
|
||||
actions.prev();
|
||||
break;
|
||||
case 'close':
|
||||
actions.skip();
|
||||
break;
|
||||
case 'download-selected':
|
||||
handleDownloadSelected();
|
||||
actions.complete();
|
||||
break;
|
||||
case 'security-next':
|
||||
if (!runtimeState.selectedRole) return;
|
||||
if (runtimeState.selectedRole !== 'admin') {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
}
|
||||
actions.complete();
|
||||
break;
|
||||
case 'launch-admin':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
|
||||
actions.complete();
|
||||
break;
|
||||
case 'launch-tools':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
actions.complete();
|
||||
break;
|
||||
case 'launch-auto': {
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType });
|
||||
actions.complete();
|
||||
break;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
markStepSeen('tour');
|
||||
actions.updateRuntimeState({ tourRequested: false });
|
||||
actions.complete();
|
||||
break;
|
||||
case 'see-plans':
|
||||
actions.complete();
|
||||
navigate('/settings/adminPlan');
|
||||
break;
|
||||
}
|
||||
}, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
|
||||
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||
|
||||
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
|
||||
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
const tourOrch = useTourOrchestration();
|
||||
const adminTourOrch = useAdminTourOrchestration();
|
||||
|
||||
const userStepsConfig = useMemo(
|
||||
() => createUserStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools: tourOrch.backToAllTools,
|
||||
selectCropTool: tourOrch.selectCropTool,
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
pinFile: tourOrch.pinFile,
|
||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||
executeTool: tourOrch.executeTool,
|
||||
openFilesModal,
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
||||
);
|
||||
|
||||
const adminStepsConfig = useMemo(
|
||||
() => createAdminStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveAdminState: adminTourOrch.saveAdminState,
|
||||
openConfigModal: adminTourOrch.openConfigModal,
|
||||
navigateToSection: adminTourOrch.navigateToSection,
|
||||
scrollNavToSection: adminTourOrch.scrollNavToSection,
|
||||
},
|
||||
}),
|
||||
[t, adminTourOrch]
|
||||
);
|
||||
|
||||
const tourSteps = useMemo<StepType[]>(() => {
|
||||
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
||||
return Object.values(config);
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep?.id === 'tour' && !isTourOpen) {
|
||||
markStepSeen('tour');
|
||||
setIsTourOpen(true);
|
||||
}
|
||||
}, [currentStep, isTourOpen, activeFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalTourRequested) {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
|
||||
markStepSeen('tour');
|
||||
setIsTourOpen(true);
|
||||
clearTourRequest();
|
||||
}
|
||||
}, [externalTourRequested, requestedTourType, actions, clearTourRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTourOpen) removeAllGlows();
|
||||
return () => removeAllGlows();
|
||||
}, [isTourOpen]);
|
||||
|
||||
const finishTour = useCallback(() => {
|
||||
setIsTourOpen(false);
|
||||
if (runtimeState.tourType === 'admin') {
|
||||
adminTourOrch.restoreAdminState();
|
||||
} else {
|
||||
tourOrch.restoreWorkbenchState();
|
||||
}
|
||||
markStepSeen('tour');
|
||||
if (currentStep?.id === 'tour') actions.complete();
|
||||
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
|
||||
|
||||
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
|
||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
||||
if (steps && tourCurrentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
finishTour();
|
||||
} else if (steps) {
|
||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||
}
|
||||
}, [finishTour]);
|
||||
|
||||
const handleCloseTour = useCallback((args: CloseArgs) => {
|
||||
args.setIsOpen(false);
|
||||
finishTour();
|
||||
}, [finishTour]);
|
||||
|
||||
const currentSlideDefinition = useMemo(() => {
|
||||
if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) {
|
||||
return null;
|
||||
}
|
||||
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
||||
}, [currentStep]);
|
||||
|
||||
const currentSlideContent = useMemo(() => {
|
||||
if (!currentSlideDefinition) return null;
|
||||
return currentSlideDefinition.createSlide({
|
||||
osLabel: osInfo.label,
|
||||
osUrl: osInfo.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: setSelectedDownloadUrl,
|
||||
selectedRole: runtimeState.selectedRole,
|
||||
onRoleSelect: handleRoleSelect,
|
||||
licenseNotice: runtimeState.licenseNotice,
|
||||
loginEnabled: serverExperience.loginEnabled,
|
||||
firstLoginUsername: runtimeState.firstLoginUsername,
|
||||
onPasswordChanged: handlePasswordChanged,
|
||||
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
|
||||
});
|
||||
}, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
|
||||
|
||||
const modalSlideCount = useMemo(() => {
|
||||
return activeFlow.filter((step) => step.type === 'modal-slide').length;
|
||||
}, [activeFlow]);
|
||||
|
||||
const currentModalSlideIndex = useMemo(() => {
|
||||
if (!currentStep || currentStep.type !== 'modal-slide') return 0;
|
||||
const modalSlides = activeFlow.filter((step) => step.type === 'modal-slide');
|
||||
return modalSlides.findIndex((step) => step.id === currentStep.id);
|
||||
}, [activeFlow, currentStep]);
|
||||
|
||||
if (onAuthRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showLicenseSlide) {
|
||||
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
|
||||
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: '',
|
||||
osUrl: '',
|
||||
osOptions: [],
|
||||
onDownloadUrlChange: () => {},
|
||||
selectedRole: null,
|
||||
onRoleSelect: () => {},
|
||||
licenseNotice: effectiveLicenseNotice,
|
||||
loginEnabled: serverExperience.loginEnabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
slideContent={slideContent}
|
||||
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
|
||||
modalSlideCount={1}
|
||||
currentModalSlideIndex={0}
|
||||
onSkip={closeLicenseSlide}
|
||||
onAction={(action) => {
|
||||
if (action === 'see-plans') {
|
||||
closeLicenseSlide();
|
||||
navigate('/settings/adminPlan');
|
||||
} else {
|
||||
closeLicenseSlide();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !isActive || !currentStep) {
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={isTourOpen}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentStep.type) {
|
||||
case 'tool-prompt':
|
||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
||||
|
||||
case 'tour':
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={true}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'analytics-modal':
|
||||
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
|
||||
|
||||
case 'modal-slide':
|
||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={currentSlideDefinition}
|
||||
slideContent={currentSlideContent}
|
||||
runtimeState={runtimeState}
|
||||
modalSlideCount={modalSlideCount}
|
||||
currentModalSlideIndex={currentModalSlideIndex}
|
||||
onSkip={actions.skip}
|
||||
onAction={handleButtonAction}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+55
-41
@@ -1,33 +1,44 @@
|
||||
/**
|
||||
* OnboardingModalSlide Component
|
||||
*
|
||||
* Renders a single modal slide in the onboarding flow.
|
||||
* Handles the hero image, content, stepper, and button actions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Stack } from '@mantine/core';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
import type { SlideConfig } from '@app/types/types';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
||||
import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types';
|
||||
import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState';
|
||||
import { SlideButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
|
||||
const flow = useInitialOnboardingState(props);
|
||||
interface OnboardingModalSlideProps {
|
||||
slideDefinition: SlideDefinition;
|
||||
slideContent: SlideConfig;
|
||||
runtimeState: OnboardingRuntimeState;
|
||||
modalSlideCount: number;
|
||||
currentModalSlideIndex: number;
|
||||
onSkip: () => void;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
state,
|
||||
totalSteps,
|
||||
currentSlide,
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
} = flow;
|
||||
export default function OnboardingModalSlide({
|
||||
slideDefinition,
|
||||
slideContent,
|
||||
runtimeState,
|
||||
modalSlideCount,
|
||||
currentModalSlideIndex,
|
||||
onSkip,
|
||||
onAction,
|
||||
}: OnboardingModalSlideProps) {
|
||||
|
||||
const renderHero = () => {
|
||||
if (slideDefinition.hero.type === 'dual-icon') {
|
||||
@@ -48,6 +59,9 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
||||
{slideDefinition.hero.type === 'shield' && (
|
||||
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'lock' && (
|
||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||
{slideDefinition.hero.type === 'logo' && (
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
@@ -58,8 +72,8 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={props.opened}
|
||||
onClose={closeAndMarkSeen}
|
||||
opened={true}
|
||||
onClose={onSkip}
|
||||
closeOnClickOutside={false}
|
||||
centered
|
||||
size="lg"
|
||||
@@ -67,48 +81,48 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
|
||||
body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' },
|
||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' },
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} className={styles.modalContent}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={currentSlide.background.gradientStops}
|
||||
circles={currentSlide.background.circles}
|
||||
gradientStops={slideContent.background.gradientStops}
|
||||
circles={slideContent.background.circles}
|
||||
isActive
|
||||
slideKey={currentSlide.key}
|
||||
slideKey={slideContent.key}
|
||||
/>
|
||||
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
|
||||
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
|
||||
{renderHero()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modalBody}>
|
||||
<div className={styles.modalBody} style={{ overflowY: 'auto', maxHeight: 'calc(90vh - 220px)' }}>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
key={`title-${currentSlide.key}`}
|
||||
key={`title-${slideContent.key}`}
|
||||
className={`${styles.title} ${styles.titleText}`}
|
||||
>
|
||||
{currentSlide.title}
|
||||
{slideContent.title}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{currentSlide.body}
|
||||
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{slideContent.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={state.step} />
|
||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
{renderButtons({
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
onAction: handleButtonAction,
|
||||
})}
|
||||
<SlideButtons
|
||||
slideDefinition={slideDefinition}
|
||||
licenseNotice={runtimeState.licenseNotice}
|
||||
flowState={{ selectedRole: runtimeState.selectedRole }}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
@@ -1,231 +1,151 @@
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { TourProvider, type StepType } from '@reactour/tour';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
/**
|
||||
* OnboardingTour Component
|
||||
*
|
||||
* Reusable tour wrapper that encapsulates all Reactour configuration.
|
||||
* Used by the main Onboarding component for both the 'tour' step and
|
||||
* when the tour is open but onboarding is inactive.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TourProvider, useTour, type StepType } from '@reactour/tour';
|
||||
import { CloseButton, ActionIcon } from '@mantine/core';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
|
||||
import ServerLicenseModal from '@app/components/onboarding/ServerLicenseModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||
import { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow';
|
||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
import TourContent from '@app/components/onboarding/TourContent';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
import i18n from "@app/i18n";
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import type { TFunction } from 'i18next';
|
||||
import i18n from '@app/i18n';
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const { t } = useTranslation();
|
||||
const flow = useOnboardingFlow();
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
restoreWorkbenchState,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
} = useTourOrchestration();
|
||||
const {
|
||||
saveAdminState,
|
||||
restoreAdminState,
|
||||
openConfigModal,
|
||||
navigateToSection,
|
||||
scrollNavToSection,
|
||||
} = useAdminTourOrchestration();
|
||||
/**
|
||||
* TourContent - Controls the tour visibility
|
||||
* Syncs the forceOpen prop with the reactour tour state.
|
||||
*/
|
||||
function TourContent({ forceOpen = false }: { forceOpen?: boolean }) {
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(forceOpen);
|
||||
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && forceOpen;
|
||||
previousIsOpenRef.current = forceOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flow.isTourOpen) {
|
||||
removeAllGlows();
|
||||
if (wasClosedNowOpen) {
|
||||
setCurrentStep(0);
|
||||
}
|
||||
return () => removeAllGlows();
|
||||
}, [flow.isTourOpen]);
|
||||
setIsOpen(forceOpen);
|
||||
}, [forceOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
const userStepsConfig = useMemo(
|
||||
() =>
|
||||
createUserStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
openFilesModal,
|
||||
},
|
||||
}),
|
||||
[
|
||||
t,
|
||||
backToAllTools,
|
||||
closeFilesModal,
|
||||
executeTool,
|
||||
loadSampleFile,
|
||||
modifyCropSettings,
|
||||
openFilesModal,
|
||||
pinFile,
|
||||
saveWorkbenchState,
|
||||
selectCropTool,
|
||||
selectFirstFile,
|
||||
switchToActiveFiles,
|
||||
switchToPageEditor,
|
||||
switchToViewer,
|
||||
],
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const adminStepsConfig = useMemo(
|
||||
() =>
|
||||
createAdminStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveAdminState,
|
||||
openConfigModal,
|
||||
navigateToSection,
|
||||
scrollNavToSection,
|
||||
},
|
||||
}),
|
||||
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
|
||||
);
|
||||
interface AdvanceArgs {
|
||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||
currentStep: number;
|
||||
steps?: StepType[];
|
||||
setIsOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const steps = useMemo<StepType[]>(() => {
|
||||
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
||||
return Object.values(config);
|
||||
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
|
||||
interface CloseArgs {
|
||||
setIsOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const advanceTour = ({
|
||||
setCurrentStep,
|
||||
currentStep,
|
||||
steps,
|
||||
setIsOpen,
|
||||
}: {
|
||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||
currentStep: number;
|
||||
steps?: StepType[];
|
||||
setIsOpen: (value: boolean) => void;
|
||||
}) => {
|
||||
if (steps && currentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
if (flow.tourType === 'admin') {
|
||||
restoreAdminState();
|
||||
} else {
|
||||
restoreWorkbenchState();
|
||||
}
|
||||
flow.handleTourCompletion();
|
||||
} else if (steps) {
|
||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||
}
|
||||
};
|
||||
interface OnboardingTourProps {
|
||||
tourSteps: StepType[];
|
||||
tourType: 'admin' | 'tools';
|
||||
isRTL: boolean;
|
||||
t: TFunction;
|
||||
isOpen: boolean;
|
||||
onAdvance: (args: AdvanceArgs) => void;
|
||||
onClose: (args: CloseArgs) => void;
|
||||
}
|
||||
|
||||
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
|
||||
setIsOpen(false);
|
||||
if (flow.tourType === 'admin') {
|
||||
restoreAdminState();
|
||||
} else {
|
||||
restoreWorkbenchState();
|
||||
}
|
||||
flow.handleTourCompletion();
|
||||
};
|
||||
export default function OnboardingTour({
|
||||
tourSteps,
|
||||
tourType,
|
||||
isRTL,
|
||||
t,
|
||||
isOpen,
|
||||
onAdvance,
|
||||
onClose,
|
||||
}: OnboardingTourProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InitialOnboardingModal {...flow.initialModalProps} />
|
||||
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
|
||||
<TourProvider
|
||||
key={`${flow.tourType}-${i18n.language}`}
|
||||
steps={steps}
|
||||
maskClassName={flow.maskClassName}
|
||||
onClickClose={handleCloseTour}
|
||||
onClickMask={advanceTour}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
e.stopPropagation();
|
||||
advanceTour(clickProps);
|
||||
}}
|
||||
keyboardHandler={(e, clickProps, status) => {
|
||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
advanceTour(clickProps);
|
||||
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
handleCloseTour(clickProps);
|
||||
}
|
||||
}}
|
||||
rtl={isRTL}
|
||||
styles={{
|
||||
popover: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
maxWidth: '400px',
|
||||
}),
|
||||
maskArea: (base) => ({
|
||||
...base,
|
||||
rx: 8,
|
||||
}),
|
||||
badge: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
||||
}),
|
||||
controls: (base) => ({
|
||||
...base,
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
}}
|
||||
highlightedMaskClassName="tour-highlight-glow"
|
||||
showNavigation={true}
|
||||
showBadge={false}
|
||||
showCloseButton={true}
|
||||
disableInteraction={true}
|
||||
disableDotsNavigation={false}
|
||||
prevButton={() => null}
|
||||
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||
const isLast = currentStep === stepsLength - 1;
|
||||
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => advanceTour({ setCurrentStep, currentStep, steps, setIsOpen })}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
||||
>
|
||||
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
|
||||
),
|
||||
Content: ({ content }: { content: string }) => (
|
||||
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TourContent />
|
||||
</TourProvider>
|
||||
<ServerLicenseModal {...flow.serverLicenseModalProps} />
|
||||
</>
|
||||
<TourProvider
|
||||
key={`${tourType}-${i18n.language}`}
|
||||
steps={tourSteps}
|
||||
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
|
||||
onClickClose={onClose}
|
||||
onClickMask={onAdvance}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
e.stopPropagation();
|
||||
onAdvance(clickProps);
|
||||
}}
|
||||
keyboardHandler={(e, clickProps, status) => {
|
||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
onAdvance(clickProps);
|
||||
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
onClose(clickProps);
|
||||
}
|
||||
}}
|
||||
rtl={isRTL}
|
||||
styles={{
|
||||
popover: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
maxWidth: '400px',
|
||||
}),
|
||||
maskArea: (base) => ({
|
||||
...base,
|
||||
rx: 8,
|
||||
}),
|
||||
badge: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
||||
}),
|
||||
controls: (base) => ({
|
||||
...base,
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
}}
|
||||
highlightedMaskClassName="tour-highlight-glow"
|
||||
showNavigation={true}
|
||||
showBadge={false}
|
||||
showCloseButton={true}
|
||||
disableInteraction={true}
|
||||
disableDotsNavigation={false}
|
||||
prevButton={() => null}
|
||||
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||
const isLast = tourCurrentStep === stepsLength - 1;
|
||||
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
||||
>
|
||||
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
|
||||
),
|
||||
Content: ({ content }: { content: string }) => (
|
||||
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TourContent forceOpen={true} />
|
||||
</TourProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export type { AdvanceArgs, CloseArgs };
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user