photo scan V2 (#5255)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2025-12-30 18:55:56 +00:00
committed by GitHub
parent 8f1af5f967
commit 70fc6348f3
38 changed files with 4394 additions and 2780 deletions
+2
View File
@@ -43,6 +43,8 @@ app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/vendor/
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle
@@ -417,6 +417,7 @@ public class ApplicationProperties {
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -0,0 +1,438 @@
package stirling.software.common.service;
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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
/**
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
*/
@Service
@Slf4j
public class MobileScannerService {
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
private final Path tempDirectory;
public MobileScannerService() throws IOException {
// Create temp directory for mobile scanner uploads
this.tempDirectory =
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
Files.createDirectories(tempDirectory);
log.info("Mobile scanner temp directory: {}", tempDirectory);
}
/**
* Register a new session (called by desktop when QR code is generated)
*
* @param sessionId Unique session identifier
* @return SessionInfo with creation time and expiry
*/
public SessionInfo createSession(String sessionId) {
validateSessionId(sessionId);
SessionData session = new SessionData(sessionId);
activeSessions.put(sessionId, session);
log.info("Created mobile scanner session: {}", sessionId);
return new SessionInfo(
sessionId,
session.createdAt,
session.createdAt + SESSION_TIMEOUT_MS,
SESSION_TIMEOUT_MS);
}
/**
* Validate if a session exists and is not expired
*
* @param sessionId Session identifier to validate
* @return SessionInfo if valid, null if invalid/expired
*/
public SessionInfo validateSession(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return null;
}
long now = System.currentTimeMillis();
long expiryTime = session.getLastAccessTime() + SESSION_TIMEOUT_MS;
// Check if expired
if (now > expiryTime) {
deleteSession(sessionId);
return null;
}
session.updateLastAccess();
return new SessionInfo(sessionId, session.createdAt, expiryTime, SESSION_TIMEOUT_MS);
}
/**
* Stores uploaded files for a session
*
* @param sessionId Unique session identifier
* @param files Files to upload
* @throws IOException If file storage fails
*/
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
validateSessionId(sessionId);
SessionData session =
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
// Create session directory
Path sessionDir = getSafeSessionDirectory(sessionId);
Files.createDirectories(sessionDir);
// Save each file
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "upload-" + System.currentTimeMillis();
}
// Sanitize filename
String safeFilename = sanitizeFilename(originalFilename);
Path filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
// Ensure resulting path stays within the session directory
if (!filePath.startsWith(sessionDir)) {
throw new IOException("Invalid filename");
}
// Handle duplicate filenames
int counter = 1;
while (Files.exists(filePath)) {
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
String ext =
safeFilename.contains(".")
? safeFilename.substring(safeFilename.lastIndexOf("."))
: "";
safeFilename = nameWithoutExt + "-" + counter + ext;
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
if (!filePath.startsWith(sessionDir)) {
throw new IOException("Invalid filename");
}
counter++;
}
file.transferTo(filePath);
session.addFile(new FileMetadata(safeFilename, file.getSize(), file.getContentType()));
log.info(
"Uploaded file for session {}: {} ({} bytes)",
sessionId,
safeFilename,
file.getSize());
}
session.updateLastAccess();
}
/**
* Retrieves file metadata for a session
*
* @param sessionId Session identifier
* @return List of file metadata, or empty list if session doesn't exist
*/
public List<FileMetadata> getSessionFiles(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return List.of();
}
session.updateLastAccess();
return new ArrayList<>(session.getFiles());
}
/**
* Retrieves actual file data for download
*
* @param sessionId Session identifier
* @param filename Filename to retrieve
* @return File path
* @throws IOException If file not found or session doesn't exist
*/
public Path getFile(String sessionId, String filename) throws IOException {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
throw new IOException("Session not found: " + sessionId);
}
Path filePath = getSafeFilePath(sessionId, filename);
if (!Files.exists(filePath)) {
throw new IOException("File not found: " + filename);
}
session.updateLastAccess();
session.markFileAsDownloaded(filename);
return filePath;
}
/**
* Deletes a file after it has been served to the client. Should be called after successful
* download.
*
* @param sessionId Session identifier
* @param filename Filename to delete
*/
public void deleteFileAfterDownload(String sessionId, String filename) {
try {
Path filePath = getSafeFilePath(sessionId, filename);
Files.deleteIfExists(filePath);
log.info("Deleted file after download: {}/{}", sessionId, filename);
// Check if all files have been downloaded - if so, delete the entire session
SessionData session = activeSessions.get(sessionId);
if (session != null && session.allFilesDownloaded()) {
deleteSession(sessionId);
log.info("All files downloaded - deleted session: {}", sessionId);
}
} catch (IOException | IllegalArgumentException e) {
log.warn("Failed to delete file after download: {}/{}", sessionId, filename, e);
}
}
/**
* Deletes a session and all its files
*
* @param sessionId Session to delete
*/
public void deleteSession(String sessionId) {
SessionData session = activeSessions.remove(sessionId);
if (session != null) {
try {
Path sessionDir = getSafeSessionDirectory(sessionId);
if (Files.exists(sessionDir)) {
// Delete all files in session directory
Files.walk(sessionDir)
.sorted(
(a, b) ->
-a.compareTo(b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
}
log.info("Deleted session: {}", sessionId);
} catch (IllegalArgumentException e) {
log.warn(
"Refused to delete session with invalid sessionId '{}': {}",
sessionId,
e.getMessage());
} catch (IOException e) {
log.error("Error deleting session directory: {}", sessionId, e);
}
}
}
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
@Scheduled(fixedRate = 5 * 60 * 1000)
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
List<String> expiredSessions = new ArrayList<>();
activeSessions.forEach(
(sessionId, session) -> {
if (now - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
expiredSessions.add(sessionId);
}
});
if (!expiredSessions.isEmpty()) {
log.info("Cleaning up {} expired mobile scanner sessions", expiredSessions.size());
expiredSessions.forEach(this::deleteSession);
}
}
private void validateSessionId(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("Session ID cannot be empty");
}
// Basic validation: alphanumeric and hyphens only
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
throw new IllegalArgumentException("Invalid session ID format");
}
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
// Ensure we have a non-empty, safe filename
if (sanitized.isBlank()) {
sanitized = "upload-" + System.currentTimeMillis();
}
return sanitized;
}
/**
* Safely resolves and validates a session directory path to prevent directory traversal
* attacks.
*
* @param sessionId Session identifier
* @return Normalized absolute path to the session directory
* @throws IllegalArgumentException if the resolved path escapes the temp directory
*/
private Path getSafeSessionDirectory(String sessionId) {
validateSessionId(sessionId);
Path baseDir = tempDirectory.normalize().toAbsolutePath();
Path sessionDir = baseDir.resolve(sessionId).normalize().toAbsolutePath();
// Verify the resolved path is still within the temp directory
if (!sessionDir.startsWith(baseDir)) {
throw new IllegalArgumentException("Invalid session ID: path traversal detected");
}
return sessionDir;
}
/**
* Safely resolves and validates a file path within a session directory to prevent directory
* traversal attacks.
*
* @param sessionId Session identifier
* @param filename Filename within the session
* @return Normalized absolute path to the file
* @throws IOException if the resolved path escapes the session directory
*/
private Path getSafeFilePath(String sessionId, String filename) throws IOException {
if (filename == null || filename.isBlank()) {
throw new IOException("Filename cannot be empty");
}
// Additional validation: reject filenames with path separators or parent references
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
throw new IOException(
"Invalid filename: contains path separators or parent references");
}
Path sessionDir = getSafeSessionDirectory(sessionId);
Path filePath = sessionDir.resolve(filename).normalize().toAbsolutePath();
// Verify the resolved path is still within the session directory
if (!filePath.startsWith(sessionDir)) {
throw new IOException("Invalid filename: path traversal detected");
}
return filePath;
}
/** Session information for client */
public static class SessionInfo {
private final String sessionId;
private final long createdAt;
private final long expiresAt;
private final long timeoutMs;
public SessionInfo(String sessionId, long createdAt, long expiresAt, long timeoutMs) {
this.sessionId = sessionId;
this.createdAt = createdAt;
this.expiresAt = expiresAt;
this.timeoutMs = timeoutMs;
}
public String getSessionId() {
return sessionId;
}
public long getCreatedAt() {
return createdAt;
}
public long getExpiresAt() {
return expiresAt;
}
public long getTimeoutMs() {
return timeoutMs;
}
}
/** File metadata for client */
public static class FileMetadata {
private final String filename;
private final long size;
private final String contentType;
public FileMetadata(String filename, long size, String contentType) {
this.filename = filename;
this.size = size;
this.contentType = contentType;
}
public String getFilename() {
return filename;
}
public long getSize() {
return size;
}
public String getContentType() {
return contentType;
}
}
/** Session data tracking */
private static class SessionData {
private final String sessionId;
private final List<FileMetadata> files = new ArrayList<>();
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
private final long createdAt;
private long lastAccessTime;
public SessionData(String sessionId) {
this.sessionId = sessionId;
this.createdAt = System.currentTimeMillis();
this.lastAccessTime = createdAt;
}
public void addFile(FileMetadata file) {
files.add(file);
downloadedFiles.put(file.getFilename(), false);
}
public List<FileMetadata> getFiles() {
return files;
}
public void markFileAsDownloaded(String filename) {
downloadedFiles.put(filename, true);
}
public boolean allFilesDownloaded() {
return !downloadedFiles.isEmpty()
&& downloadedFiles.values().stream().allMatch(downloaded -> downloaded);
}
public void updateLastAccess() {
this.lastAccessTime = System.currentTimeMillis();
}
public long getLastAccessTime() {
return lastAccessTime;
}
}
}
@@ -52,6 +52,11 @@ public class RequestUriUtils {
return true;
}
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
if (normalizedUri.startsWith("/mobile-scanner")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@@ -168,6 +173,8 @@ public class RequestUriUtils {
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs");
}
@@ -32,7 +32,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
"principal",
"startDate",
"endDate",
"async");
"async",
"session");
@Override
public boolean preHandle(
@@ -71,6 +71,15 @@ public class ConfigController {
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
// Add frontendUrl for mobile scanner QR codes
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
// Add mobile scanner setting
configData.put(
"enableMobileScanner",
applicationProperties.getSystem().isEnableMobileScanner());
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
@@ -0,0 +1,302 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.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.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.MobileScannerService;
import stirling.software.common.service.MobileScannerService.FileMetadata;
/**
* REST controller for mobile scanner functionality. Allows mobile devices to upload scanned images
* that can be retrieved by desktop clients via a session-based system. No authentication required
* for peer-to-peer scanning workflow.
*/
@RestController
@RequestMapping("/api/v1/mobile-scanner")
@Tag(
name = "Mobile Scanner",
description =
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
@Hidden
@Slf4j
public class MobileScannerController {
private final MobileScannerService mobileScannerService;
public MobileScannerController(MobileScannerService mobileScannerService) {
this.mobileScannerService = mobileScannerService;
}
/**
* Create a new session (called by desktop when QR code is generated)
*
* @param sessionId Unique session identifier
* @return Session information with expiry time
*/
@PostMapping("/create-session/{sessionId}")
@Operation(
summary = "Create a new mobile scanner session",
description = "Desktop clients call this when generating a QR code")
@ApiResponse(
responseCode = "200",
description = "Session created successfully",
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid session ID")
public ResponseEntity<Map<String, Object>> createSession(
@Parameter(description = "Session ID for QR code", required = true) @PathVariable
String sessionId) {
try {
MobileScannerService.SessionInfo sessionInfo =
mobileScannerService.createSession(sessionId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("sessionId", sessionInfo.getSessionId());
response.put("createdAt", sessionInfo.getCreatedAt());
response.put("expiresAt", sessionInfo.getExpiresAt());
response.put("timeoutMs", sessionInfo.getTimeoutMs());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Invalid session creation request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
/**
* Validate if a session exists and is not expired
*
* @param sessionId Session identifier to validate
* @return Session information if valid, error if invalid/expired
*/
@GetMapping("/validate-session/{sessionId}")
@Operation(
summary = "Validate a mobile scanner session",
description = "Check if session exists and is not expired")
@ApiResponse(
responseCode = "200",
description = "Session is valid",
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
@ApiResponse(responseCode = "404", description = "Session not found or expired")
public ResponseEntity<Map<String, Object>> validateSession(
@Parameter(description = "Session ID to validate", required = true) @PathVariable
String sessionId) {
MobileScannerService.SessionInfo sessionInfo =
mobileScannerService.validateSession(sessionId);
if (sessionInfo == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("valid", false, "error", "Session not found or expired"));
}
Map<String, Object> response = new HashMap<>();
response.put("valid", true);
response.put("sessionId", sessionInfo.getSessionId());
response.put("createdAt", sessionInfo.getCreatedAt());
response.put("expiresAt", sessionInfo.getExpiresAt());
response.put("timeoutMs", sessionInfo.getTimeoutMs());
return ResponseEntity.ok(response);
}
/**
* Upload files from mobile device
*
* @param sessionId Unique session identifier from QR code
* @param files Files to upload
* @return Upload status
*/
@PostMapping("/upload/{sessionId}")
@Operation(
summary = "Upload scanned files from mobile device",
description = "Mobile devices upload scanned images to a temporary session")
@ApiResponse(
responseCode = "200",
description = "Files uploaded successfully",
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
@ApiResponse(responseCode = "500", description = "Upload failed")
public ResponseEntity<Map<String, Object>> uploadFiles(
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
String sessionId,
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
List<MultipartFile> files) {
try {
if (files == null || files.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
}
mobileScannerService.uploadFiles(sessionId, files);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("sessionId", sessionId);
response.put("filesUploaded", files.size());
response.put("message", "Files uploaded successfully");
log.info("Mobile scanner upload: session={}, files={}", sessionId, files.size());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Invalid mobile scanner upload request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (IOException e) {
log.error("Failed to upload files for session: {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to save files"));
}
}
/**
* Get list of uploaded files for a session
*
* @param sessionId Session identifier
* @return List of file metadata
*/
@GetMapping("/files/{sessionId}")
@Operation(
summary = "Get uploaded files for a session",
description = "Desktop clients poll this endpoint to check for new uploads")
@ApiResponse(
responseCode = "200",
description = "File list retrieved",
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
public ResponseEntity<Map<String, Object>> getSessionFiles(
@Parameter(description = "Session ID", required = true) @PathVariable
String sessionId) {
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
Map<String, Object> response = new HashMap<>();
response.put("sessionId", sessionId);
response.put("files", files);
response.put("count", files.size());
return ResponseEntity.ok(response);
}
/**
* Download a specific file from a session
*
* @param sessionId Session identifier
* @param filename Filename to download
* @return File content
*/
@GetMapping("/download/{sessionId}/{filename}")
@Operation(
summary = "Download a specific file",
description =
"Download a file that was uploaded to a session. File is automatically deleted after download.")
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
@ApiResponse(responseCode = "404", description = "File or session not found")
public ResponseEntity<Resource> downloadFile(
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
@Parameter(description = "Filename to download", required = true) @PathVariable
String filename) {
try {
Path filePath = mobileScannerService.getFile(sessionId, filename);
// Read file into memory first, so we can delete it before sending
byte[] fileBytes = Files.readAllBytes(filePath);
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
// Delete file immediately after reading into memory (server-side cleanup)
mobileScannerService.deleteFileAfterDownload(sessionId, filename);
// Serve from memory
Resource resource = new org.springframework.core.io.ByteArrayResource(fileBytes);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"")
.body(resource);
} catch (IOException e) {
log.warn("File not found: session={}, file={}", sessionId, filename);
return ResponseEntity.notFound().build();
}
}
/**
* Delete a session and all its files
*
* @param sessionId Session to delete
* @return Deletion status
*/
@DeleteMapping("/session/{sessionId}")
@Operation(
summary = "Delete a session",
description = "Manually delete a session and all its uploaded files")
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
public ResponseEntity<Map<String, Object>> deleteSession(
@Parameter(description = "Session ID to delete", required = true) @PathVariable
String sessionId) {
mobileScannerService.deleteSession(sessionId);
return ResponseEntity.ok(
Map.of("success", true, "sessionId", sessionId, "message", "Session deleted"));
}
// Response schemas for OpenAPI documentation
private static class SessionInfoResponse {
public boolean success;
public String sessionId;
public long createdAt;
public long expiresAt;
public long timeoutMs;
}
private static class UploadResponse {
public boolean success;
public String sessionId;
public int filesUploaded;
public String message;
}
private static class FileListResponse {
public String sessionId;
public List<FileMetadata> files;
public int count;
}
}
@@ -115,13 +115,13 @@ public class ReactRoutingController {
}
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
@@ -145,6 +145,7 @@ system:
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
@@ -1,53 +1,99 @@
/* Light theme variables */
:root {
--cc-bg: #ffffff;
--cc-primary-color: #1c1c1c;
--cc-secondary-color: #666666;
--cc-btn-primary-bg: #007BFF;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #007BFF;
--cc-btn-primary-hover-bg: #0056b3;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #0056b3;
--cc-btn-secondary-bg: #f1f3f4;
--cc-btn-secondary-color: #1c1c1c;
--cc-btn-secondary-border-color: #f1f3f4;
--cc-btn-secondary-hover-bg: #007BFF;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #007BFF;
--cc-separator-border-color: #e0e0e0;
--cc-toggle-on-bg: #007BFF;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747E;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-section-category-border: #e0e0e0;
--cc-cookie-category-block-bg: #f1f3f4;
--cc-cookie-category-block-border: #f1f3f4;
--cc-cookie-category-block-hover-bg: #e9eff4;
--cc-cookie-category-block-hover-border: #e9eff4;
--cc-cookie-category-expanded-block-bg: #f1f3f4;
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
--cc-footer-bg: #ffffff;
--cc-footer-color: #1c1c1c;
--cc-footer-border-color: #ffffff;
}
/* Dark theme variables */
.cc--darkmode{
--cc-bg: var(--md-sys-color-inverse-on-surface);
--cc-primary-color: var(--md-sys-color-on-surface);
--cc-secondary-color: var(--md-sys-color-on-surface);
--cc-bg: #2d2d2d;
--cc-primary-color: #e5e5e5;
--cc-secondary-color: #b0b0b0;
--cc-btn-primary-bg: var(--md-sys-color-secondary);
--cc-btn-primary-color: var(--cc-bg);
--cc-btn-primary-border-color: var(--cc-btn-primary-bg);
--cc-btn-primary-hover-bg: var(--md-sys-color-surface-3);
--cc-btn-primary-hover-color: var(--md-sys-color-on-secondary-container);
--cc-btn-primary-hover-border-color: var(--md-sys-color-surface-3);
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-secondary-bg: var(--md-sys-color-surface-3);
--cc-btn-secondary-color: var(--md-sys-color-on-secondary-container);
--cc-btn-secondary-border-color: var(--md-sys-color-surface-3);
--cc-btn-secondary-hover-bg:var(--md-sys-color-secondary);
--cc-btn-secondary-hover-color: var(--cc-bg);
--cc-btn-secondary-hover-border-color: var(--md-sys-color-secondary);
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-separator-border-color: var(--md-sys-color-outline);
--cc-separator-border-color: #555555;
--cc-toggle-on-bg: var(--cc-btn-primary-bg);
--cc-toggle-off-bg: var(--md-sys-color-outline);
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
--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: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
--cc-toggle-readonly-knob-icon-color: var(--cc-toggle-readonly-bg);
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-section-category-border: var(--md-sys-color-outline);
--cc-section-category-border: #555555;
--cc-cookie-category-block-bg: 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-block-bg: #3d3d3d;
--cc-cookie-category-block-border: #3d3d3d;
--cc-cookie-category-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-hover-border: #4d4d4d;
--cc-cookie-category-expanded-block-bg: #3d3d3d;
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
/* --cc-overlay-bg: rgba(0, 0, 0, 0.65);
--cc-webkit-scrollbar-bg: var(--cc-section-category-border);
--cc-webkit-scrollbar-hover-bg: var(--cc-btn-primary-hover-bg);
*/
--cc-footer-bg: var(--cc-bg);
--cc-footer-color: var(--cc-primary-color);
--cc-footer-border-color: var(--cc-bg);
--cc-footer-bg: #2d2d2d;
--cc-footer-color: #e5e5e5;
--cc-footer-border-color: #2d2d2d;
}
.cm__body{
max-width: 90% !important;
@@ -78,7 +124,83 @@
}
}
/* Toggle visibility fixes */
#cc-main .section__toggle {
opacity: 0 !important; /* Keep invisible but functional */
}
#cc-main .toggle__icon {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
}
#cc-main .toggle__icon-circle {
display: block !important;
position: absolute !important;
transition: transform 0.25s ease !important;
}
#cc-main .toggle__icon-on,
#cc-main .toggle__icon-off {
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: absolute !important;
width: 100% !important;
height: 100% !important;
}
/* Ensure toggles are visible in both themes */
#cc-main .toggle__icon {
background: var(--cc-toggle-off-bg) !important;
border: 1px solid var(--cc-toggle-off-bg) !important;
}
#cc-main .section__toggle:checked ~ .toggle__icon {
background: var(--cc-toggle-on-bg) !important;
border: 1px solid var(--cc-toggle-on-bg) !important;
}
/* Ensure toggle text is visible */
#cc-main .pm__section-title {
color: var(--cc-primary-color) !important;
}
#cc-main .pm__section-desc {
color: var(--cc-secondary-color) !important;
}
/* Make sure the modal has proper contrast */
#cc-main .pm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
/* 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;
}
@@ -1,20 +1,25 @@
{
"name": "Stirling-PDF",
"short_name": "Stirling-PDF",
"short_name": "Stirling PDF",
"name": "Stirling PDF",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
"src": "modern-logo/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
"src": "modern-logo/logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "modern-logo/logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/",
"start_url": ".",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000"
"theme_color": "#000000",
"background_color": "#ffffff"
}
+3
View File
@@ -19,5 +19,8 @@
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="src/index.tsx"></script>
<!-- jscanify and OpenCV for mobile scanner - loaded after React for non-blocking page load -->
<script src="/vendor/jscanify/opencv.js" async></script>
<script src="/vendor/jscanify/jscanify.js" async></script>
</body>
</html>
+1151 -2563
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -54,7 +54,9 @@
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
"qrcode.react": "^4.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
@@ -963,6 +963,7 @@ desc = "Add custom text anywhere in your PDF"
addFiles = "Add Files"
uploadFromComputer = "Upload from computer"
openFromComputer = "Open from computer"
mobileUpload = "Upload from Mobile"
[viewPdf]
tags = "view,read,annotate,text,image,highlight,edit"
@@ -4360,6 +4361,13 @@ description = "Path to WeasyPrint executable for HTML to PDF conversion (leave e
label = "Unoconvert Executable"
description = "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)"
[admin.settings.general.frontendUrl]
label = "Frontend URL"
description = "Base URL for frontend (e.g., https://pdf.example.com). Used for email invite links and mobile QR code uploads. Leave empty to use backend URL."
[admin.settings.badge]
clickToUpgrade = "Click to view plan details"
[admin.settings.security]
title = "Security"
description = "Configure authentication, login behaviour, and security policies."
@@ -4564,6 +4572,13 @@ description = "Automatically create user accounts on first SAML2 login"
label = "Block Registration"
description = "Prevent new user registration via SAML2"
[admin.settings.connections.mobileScanner]
label = "Mobile Phone Upload"
enable = "Enable QR Code Upload"
description = "Allow users to upload files from mobile devices by scanning a QR code"
note = "Note: Requires Frontend URL to be configured. "
link = "Configure in System Settings"
[admin.settings.database]
title = "Database"
description = "Configure custom database connection settings for enterprise deployments."
@@ -4745,6 +4760,10 @@ description = "Allow admins to invite users via email with auto-generated passwo
label = "Frontend URL"
description = "Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL."
[admin.settings.mail.frontendUrlNote]
note = "Note: Requires Frontend URL to be configured. "
link = "Configure in System Settings"
[admin.settings.legal]
title = "Legal Documents"
description = "Configure links to legal documents and policies."
@@ -4930,6 +4949,8 @@ googleDriveShort = "Drive"
myFiles = "My Files"
noRecentFiles = "No recent files found"
googleDriveNotAvailable = "Google Drive integration not available"
mobileUpload = "Mobile Upload"
mobileShort = "Mobile"
downloadSelected = "Download Selected"
saveSelected = "Save Selected"
openFiles = "Open Files"
@@ -6399,3 +6420,56 @@ title = "Add Text Results"
[addText.error]
failed = "An error occurred while adding text to the PDF."
[mobileUpload]
title = "Upload from Mobile"
description = "Scan this QR code with your mobile device to upload photos directly to this page."
error = "Connection Error"
pollingError = "Error checking for files"
sessionId = "Session ID"
sessionCreateError = "Failed to create session"
expiryWarning = "Session Expiring Soon"
expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically."
filesReceived = "{{count}} file(s) received"
connected = "Mobile device connected"
instructions = "Open the camera app on your phone and scan this code. Files will be transferred directly between devices."
[mobileScanner]
title = "Mobile Scanner"
noSession = "Invalid Session"
noSessionMessage = "Please scan a valid QR code to access this page."
validating = "Validating session..."
sessionInvalid = "Session Error"
sessionExpired = "This session has expired. Please refresh and try again."
sessionNotFound = "Session not found. Please refresh and try again."
sessionValidationError = "Unable to verify session. Please try again."
uploadSuccess = "Upload Successful!"
uploadSuccessMessage = "Your images have been transferred."
httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost."
uploadFailed = "Upload failed. Please try again."
uploading = "Uploading..."
connected = "Connected"
connecting = "Connecting..."
chooseMethod = "Choose Upload Method"
chooseMethodDescription = "Select how you want to scan and upload documents"
camera = "Camera"
cameraDescription = "Scan documents using your device camera with automatic edge detection"
fileUpload = "File Upload"
fileDescription = "Upload existing photos or documents from your device"
cameraAccessDenied = "Camera access denied. Please enable camera access."
back = "Back"
settings = "Settings"
edgeDetection = "Edge Detection"
flashlight = "Flashlight"
flash = "Flash"
processing = "Processing..."
capture = "Capture Photo"
selectFilesPrompt = "Select files to upload"
selectImage = "Select Image"
preview = "Preview"
retake = "Retake"
addToBatch = "Add to Batch"
upload = "Upload"
batchImages = "Batch"
clearBatch = "Clear"
uploadAll = "Upload All"
+263
View File
@@ -0,0 +1,263 @@
/*! jscanify v1.4.0 | (c) ColonelParrot and other contributors | MIT License */
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory())
: typeof define === "function" && define.amd
? define(factory)
: (global.jscanify = factory());
})(this, function () {
"use strict";
/**
* Calculates distance between two points. Each point must have `x` and `y` property
* @param {*} p1 point 1
* @param {*} p2 point 2
* @returns distance between two points
*/
function distance(p1, p2) {
return Math.hypot(p1.x - p2.x, p1.y - p2.y);
}
class jscanify {
constructor() { }
/**
* Finds the contour of the paper within the image
* @param {*} img image to process (cv.Mat)
* @returns the biggest contour inside the image
*/
findPaperContour(img) {
const imgGray = new cv.Mat();
cv.Canny(img, imgGray, 50, 200);
const imgBlur = new cv.Mat();
cv.GaussianBlur(
imgGray,
imgBlur,
new cv.Size(3, 3),
0,
0,
cv.BORDER_DEFAULT
);
const imgThresh = new cv.Mat();
cv.threshold(
imgBlur,
imgThresh,
0,
255,
cv.THRESH_OTSU
);
let contours = new cv.MatVector();
let hierarchy = new cv.Mat();
cv.findContours(
imgThresh,
contours,
hierarchy,
cv.RETR_CCOMP,
cv.CHAIN_APPROX_SIMPLE
);
let maxArea = 0;
let maxContourIndex = -1;
for (let i = 0; i < contours.size(); ++i) {
let contourArea = cv.contourArea(contours.get(i));
if (contourArea > maxArea) {
maxArea = contourArea;
maxContourIndex = i;
}
}
const maxContour =
maxContourIndex >= 0 ?
contours.get(maxContourIndex) :
null;
imgGray.delete();
imgBlur.delete();
imgThresh.delete();
contours.delete();
hierarchy.delete();
return maxContour;
}
/**
* Highlights the paper detected inside the image.
* @param {*} image image to process
* @param {*} options options for highlighting. Accepts `color` and `thickness` parameter
* @returns `HTMLCanvasElement` with original image and paper highlighted
*/
highlightPaper(image, options) {
options = options || {};
options.color = options.color || "orange";
options.thickness = options.thickness || 10;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = cv.imread(image);
const maxContour = this.findPaperContour(img);
cv.imshow(canvas, img);
if (maxContour) {
const {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
} = this.getCornerPoints(maxContour, img);
if (
topLeftCorner &&
topRightCorner &&
bottomLeftCorner &&
bottomRightCorner
) {
ctx.strokeStyle = options.color;
ctx.lineWidth = options.thickness;
ctx.beginPath();
ctx.moveTo(...Object.values(topLeftCorner));
ctx.lineTo(...Object.values(topRightCorner));
ctx.lineTo(...Object.values(bottomRightCorner));
ctx.lineTo(...Object.values(bottomLeftCorner));
ctx.lineTo(...Object.values(topLeftCorner));
ctx.stroke();
}
}
img.delete();
return canvas;
}
/**
* Extracts and undistorts the image detected within the frame.
*
* Returns `null` if no paper is detected.
*
* @param {*} image image to process
* @param {*} resultWidth desired result paper width
* @param {*} resultHeight desired result paper height
* @param {*} cornerPoints optional custom corner points, in case automatic corner points are incorrect
* @returns `HTMLCanvasElement` containing undistorted image
*/
extractPaper(image, resultWidth, resultHeight, cornerPoints) {
const canvas = document.createElement("canvas");
const img = cv.imread(image);
const maxContour = cornerPoints ? null : this.findPaperContour(img);
if(maxContour == null && cornerPoints === undefined){
return null;
}
const {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
} = cornerPoints || this.getCornerPoints(maxContour, img);
let warpedDst = new cv.Mat();
let dsize = new cv.Size(resultWidth, resultHeight);
let srcTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
topLeftCorner.x,
topLeftCorner.y,
topRightCorner.x,
topRightCorner.y,
bottomLeftCorner.x,
bottomLeftCorner.y,
bottomRightCorner.x,
bottomRightCorner.y,
]);
let dstTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
0,
0,
resultWidth,
0,
0,
resultHeight,
resultWidth,
resultHeight,
]);
let M = cv.getPerspectiveTransform(srcTri, dstTri);
cv.warpPerspective(
img,
warpedDst,
M,
dsize,
cv.INTER_LINEAR,
cv.BORDER_CONSTANT,
new cv.Scalar()
);
cv.imshow(canvas, warpedDst);
img.delete()
warpedDst.delete()
return canvas;
}
/**
* Calculates the corner points of a contour.
* @param {*} contour contour from {@link findPaperContour}
* @returns object with properties `topLeftCorner`, `topRightCorner`, `bottomLeftCorner`, `bottomRightCorner`, each with `x` and `y` property
*/
getCornerPoints(contour) {
let rect = cv.minAreaRect(contour);
const center = rect.center;
let topLeftCorner;
let topLeftCornerDist = 0;
let topRightCorner;
let topRightCornerDist = 0;
let bottomLeftCorner;
let bottomLeftCornerDist = 0;
let bottomRightCorner;
let bottomRightCornerDist = 0;
for (let i = 0; i < contour.data32S.length; i += 2) {
const point = { x: contour.data32S[i], y: contour.data32S[i + 1] };
const dist = distance(point, center);
if (point.x < center.x && point.y < center.y) {
// top left
if (dist > topLeftCornerDist) {
topLeftCorner = point;
topLeftCornerDist = dist;
}
} else if (point.x > center.x && point.y < center.y) {
// top right
if (dist > topRightCornerDist) {
topRightCorner = point;
topRightCornerDist = dist;
}
} else if (point.x < center.x && point.y > center.y) {
// bottom left
if (dist > bottomLeftCornerDist) {
bottomLeftCorner = point;
bottomLeftCornerDist = dist;
}
} else if (point.x > center.x && point.y > center.y) {
// bottom right
if (dist > bottomRightCornerDist) {
bottomRightCorner = point;
bottomRightCornerDist = dist;
}
}
}
return {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
};
}
}
return jscanify;
});
File diff suppressed because one or more lines are too long
+39 -6
View File
@@ -1,8 +1,12 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import HomePage from "@app/pages/HomePage";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -13,15 +17,44 @@ import "@app/styles/index.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}
@@ -1,12 +1,14 @@
import React from 'react';
import React, { useState } from 'react';
import { Stack, Text, Button, Group } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import CloudIcon from '@mui/icons-material/Cloud';
import PhonelinkIcon from '@mui/icons-material/Phonelink';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -15,12 +17,13 @@ interface FileSourceButtonsProps {
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const UploadIcon = icons.upload;
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const handleGoogleDriveClick = async () => {
try {
@@ -33,6 +36,16 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
}
};
const handleMobileUploadClick = () => {
setMobileUploadModalOpen(true);
};
const handleFilesReceivedFromMobile = (files: File[]) => {
if (files.length > 0) {
onNewFilesSelect(files);
}
};
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
@@ -105,24 +118,59 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
leftSection={<PhonelinkIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleMobileUploadClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)'
}
}
}}
>
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
</Button>
</>
);
if (horizontal) {
return (
<Group gap="xs" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
<>
<Group gap="xs" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
}
return (
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
<>
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
};
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Container, Button, Group, useMantineColorScheme, ActionIcon, Tooltip } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useTranslation } from 'react-i18next';
@@ -11,6 +11,9 @@ import { useLogoVariant } from '@app/hooks/useLogoVariant';
import { useFileManager } from '@app/hooks/useFileManager';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useIsMobile } from '@app/hooks/useIsMobile';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -24,8 +27,11 @@ const LandingPage = () => {
const { wordmark } = useLogoAssets();
const { loadRecentFiles } = useFileManager();
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
const [mobileUploadModalOpen, setMobileUploadModalOpen] = React.useState(false);
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const { config } = useAppConfig();
const isMobile = useIsMobile();
const handleFileDrop = async (files: File[]) => {
await addFiles(files);
@@ -48,6 +54,16 @@ const LandingPage = () => {
event.target.value = '';
};
const handleMobileUploadClick = () => {
setMobileUploadModalOpen(true);
};
const handleFilesReceivedFromMobile = async (files: File[]) => {
if (files.length > 0) {
await addFiles(files);
}
};
// Determine if the user has any recent files (same source as File Manager)
useEffect(() => {
let isMounted = true;
@@ -202,32 +218,72 @@ const LandingPage = () => {
</span>
)}
</Button>
{config?.enableMobileScanner && !isMobile && (
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
<ActionIcon
size={38}
variant="subtle"
onClick={handleMobileUploadClick}
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--accent-interactive)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
paddingLeft: '0.5rem',
paddingRight: '0.5rem',
}}
>
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
</ActionIcon>
</Tooltip>
)}
</>
)}
{!hasRecents && (
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: '100%',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
</Button>
<>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: 'calc(100% - 38px - 0.6rem)',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
</Button>
{config?.enableMobileScanner && !isMobile && (
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
<ActionIcon
size={38}
variant="subtle"
onClick={handleMobileUploadClick}
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--accent-interactive)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
paddingLeft: '0.5rem',
paddingRight: '0.5rem',
}}
>
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
</ActionIcon>
</Tooltip>
)}
</>
)}
</div>
@@ -251,6 +307,11 @@ const LandingPage = () => {
</span>
</div>
</Dropzone>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</Container>
);
};
@@ -0,0 +1,332 @@
import { useEffect, useCallback, useState, useRef } from 'react';
import { Modal, Stack, Text, Badge, Box, Alert } from '@mantine/core';
import { QRCodeSVG } from 'qrcode.react';
import { useTranslation } from 'react-i18next';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { withBasePath } from '@app/constants/app';
interface MobileUploadModalProps {
opened: boolean;
onClose: () => void;
onFilesReceived: (files: File[]) => void;
}
// Generate a cryptographically secure UUID v4-like session ID
function generateSessionId(): string {
// Use Web Crypto API for cryptographically secure random values
const cryptoObj = typeof crypto !== 'undefined' ? crypto : (window as any).crypto;
if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);
// Set version (4) and variant bits per RFC 4122
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
// Convert bytes to hex string in UUID format
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4).join(''),
hex.slice(4, 6).join(''),
hex.slice(6, 8).join(''),
hex.slice(8, 10).join(''),
hex.slice(10, 16).join(''),
].join('-');
}
// If Web Crypto is not available, fail fast rather than using insecure randomness
console.error('Web Crypto API not available. Cannot generate secure session ID.');
throw new Error('Web Crypto API not available. Cannot generate secure session ID.');
}
interface SessionInfo {
sessionId: string;
createdAt: number;
expiresAt: number;
timeoutMs: number;
}
/**
* MobileUploadModal
*
* Displays a QR code that mobile devices can scan to upload files via backend server.
* Files are temporarily stored on server and retrieved by desktop.
*/
export default function MobileUploadModal({ opened, onClose, onFilesReceived }: MobileUploadModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const [sessionId, setSessionId] = useState(() => generateSessionId());
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
const [filesReceived, setFilesReceived] = useState(0);
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
const [showExpiryWarning, setShowExpiryWarning] = useState(false);
const pollIntervalRef = useRef<number | null>(null);
const timerIntervalRef = useRef<number | null>(null);
const processedFiles = useRef<Set<string>>(new Set());
// Use configured frontendUrl if set, otherwise use current origin
// Combine with base path and mobile-scanner route
const frontendUrl = config?.frontendUrl || window.location.origin;
const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`;
// Create session on backend
const createSession = useCallback(async (newSessionId: string) => {
try {
const response = await fetch(`/api/v1/mobile-scanner/create-session/${newSessionId}`, {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to create session');
}
const data = await response.json();
setSessionInfo(data);
setError(null);
console.log('Session created:', data);
} catch (err) {
console.error('Failed to create session:', err);
setError(t('mobileUpload.sessionCreateError', 'Failed to create session'));
}
}, [t]);
// Regenerate session (when expired or warned)
const regenerateSession = useCallback(() => {
const newSessionId = generateSessionId();
setSessionId(newSessionId);
setShowExpiryWarning(false);
setFilesReceived(0);
processedFiles.current.clear();
createSession(newSessionId);
}, [createSession]);
const pollForFiles = useCallback(async () => {
if (!opened) return;
try {
const response = await fetch(`/api/v1/mobile-scanner/files/${sessionId}`);
if (!response.ok) {
throw new Error('Failed to check for files');
}
const data = await response.json();
const files = data.files || [];
// Download only files we haven't processed yet
const newFiles = files.filter((f: any) => !processedFiles.current.has(f.filename));
if (newFiles.length > 0) {
for (const fileMetadata of newFiles) {
try {
const downloadResponse = await fetch(
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`
);
if (downloadResponse.ok) {
const blob = await downloadResponse.blob();
const file = new File([blob], fileMetadata.filename, {
type: fileMetadata.contentType || 'image/jpeg'
});
processedFiles.current.add(fileMetadata.filename);
setFilesReceived((prev) => prev + 1);
onFilesReceived([file]);
}
} catch (err) {
console.error('Failed to download file:', fileMetadata.filename, err);
}
}
// Delete the entire session immediately after downloading all files
// This ensures files are only on server for ~1 second
try {
await fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' });
console.log('Session cleaned up after file download');
} catch (cleanupErr) {
console.warn('Failed to cleanup session after download:', cleanupErr);
}
}
} catch (err) {
console.error('Error polling for files:', err);
setError(t('mobileUpload.pollingError', 'Error checking for files'));
}
}, [opened, sessionId, onFilesReceived, t]);
// Create session when modal opens
useEffect(() => {
if (opened) {
createSession(sessionId);
setFilesReceived(0);
setError(null);
setShowExpiryWarning(false);
processedFiles.current.clear();
} else {
// Clean up session when modal closes
if (sessionId) {
fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' })
.catch(err => console.warn('Failed to cleanup session on close:', err));
}
}
}, [opened]); // Only run when opened changes
// Start polling for files when modal opens
useEffect(() => {
if (opened && sessionInfo) {
// Poll every 2 seconds
pollIntervalRef.current = window.setInterval(pollForFiles, 2000);
// Initial poll
pollForFiles();
} else {
// Stop polling when modal closes
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, [opened, sessionInfo, pollForFiles]);
// Session timeout timer
useEffect(() => {
if (!opened || !sessionInfo) return;
const updateTimer = () => {
const now = Date.now();
const remaining = sessionInfo.expiresAt - now;
if (remaining <= 0) {
// Session expired - regenerate
setShowExpiryWarning(false);
regenerateSession();
} else if (remaining <= 60000 && !showExpiryWarning) {
// Less than 1 minute remaining - show warning
setShowExpiryWarning(true);
}
setTimeRemaining(Math.max(0, remaining));
};
// Update immediately
updateTimer();
// Update every second
timerIntervalRef.current = window.setInterval(updateTimer, 1000);
return () => {
if (timerIntervalRef.current) {
clearInterval(timerIntervalRef.current);
}
};
}, [opened, sessionInfo, showExpiryWarning, regenerateSession]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t('mobileUpload.title', 'Upload from Mobile')}
centered
size="md"
radius="lg"
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
overlayProps={{ opacity: 0.35, blur: 2 }}
styles={{
body: {
paddingTop: '1.5rem',
},
}}
>
<Stack gap="md">
<Alert
icon={<InfoRoundedIcon style={{ fontSize: '1rem' }} />}
color="blue"
variant="light"
>
<Text size="sm">
{t(
'mobileUpload.description',
'Scan this QR code with your mobile device to upload photos directly to this page.'
)}
</Text>
</Alert>
{showExpiryWarning && timeRemaining !== null && (
<Alert
icon={<WarningRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.expiryWarning', 'Session Expiring Soon')}
color="orange"
>
<Text size="sm">
{t(
'mobileUpload.expiryWarningMessage',
'This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.',
{ seconds: Math.ceil(timeRemaining / 1000) }
)}
</Text>
</Alert>
)}
{error && (
<Alert
icon={<ErrorRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.error', 'Connection Error')}
color="red"
>
<Text size="sm">{error}</Text>
</Alert>
)}
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Box
style={{
padding: '1.5rem',
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
</Box>
{filesReceived > 0 && (
<Badge variant="filled" color="green" size="lg" leftSection={<CheckRoundedIcon style={{ fontSize: '1rem' }} />}>
{t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })}
</Badge>
)}
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
{t(
'mobileUpload.instructions',
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
)}
</Text>
<Text
size="xs"
c="dimmed"
style={{
wordBreak: 'break-all',
textAlign: 'center',
fontFamily: 'monospace',
}}
>
{mobileUrl}
</Text>
</Box>
</Stack>
</Modal>
);
}
@@ -326,20 +326,22 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
);
return (
<Tooltip
position="right"
arrow
offset={8}
open={tooltipOpen}
manualCloseOnly={manualCloseOnly}
showCloseButton={showCloseButton}
closeOnOutside={false}
openOnFocus={false}
content={toursTooltipContent}
onOpenChange={handleTooltipOpenChange}
>
{helpButtonNode}
</Tooltip>
<React.Fragment key={buttonConfig.id}>
<Tooltip
position="right"
arrow
offset={8}
open={tooltipOpen}
manualCloseOnly={manualCloseOnly}
showCloseButton={showCloseButton}
closeOnOutside={false}
openOnFocus={false}
content={toursTooltipContent}
onOpenChange={handleTooltipOpenChange}
>
{helpButtonNode}
</Tooltip>
</React.Fragment>
);
}
@@ -17,7 +17,7 @@ export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps)
return (
<Alert
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
icon={<LocalIcon icon="lock" width={20} height={20} />}
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
color="blue"
variant="light"
@@ -193,7 +193,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
size="sm"
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
onClick={() => setUpdateModalOpened(true)}
leftSection={<LocalIcon icon="system-update-rounded" width="1rem" height="1rem" />}
leftSection={<LocalIcon icon="system-update-alt-rounded" width="1rem" height="1rem" />}
>
{t('settings.general.updates.viewDetails', 'View Details')}
</Button>
@@ -28,12 +28,12 @@ function getDefaultIconName(t: ToastInstance): string {
case 'success':
return 'check-circle-rounded';
case 'error':
return 'close-rounded';
return 'cancel';
case 'warning':
return 'warning-rounded';
return 'warning';
case 'neutral':
default:
return 'info-rounded';
return 'info';
}
}
@@ -63,7 +63,7 @@ export default function ToastRenderer() {
{/* Icon */}
<div className="toast-icon">
{t.icon ?? (
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
<LocalIcon icon={getDefaultIconName(t)} width={20} height={20} />
)}
</div>
@@ -86,7 +86,7 @@ export default function ToastRenderer() {
}}
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
>
<LocalIcon icon="material-symbols:expand-more-rounded" />
<LocalIcon icon="expand-more-rounded" />
</button>
)}
<button
@@ -94,7 +94,7 @@ export default function ToastRenderer() {
onClick={() => dismiss(t.id)}
className="toast-button"
>
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
<LocalIcon icon="close" width={20} height={20} />
</button>
</div>
</div>
@@ -43,7 +43,7 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals
component="label"
htmlFor="attachments-input"
disabled={disabled}
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
>
{parameters.attachments?.length > 0
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
@@ -234,7 +234,7 @@ export const SavedSignaturesSection = ({
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:person-rounded" width={18} height={18} />
<LocalIcon icon="person-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.personalHeading', 'Personal Signatures')}
</Text>
@@ -257,7 +257,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActivePersonalIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activePersonalIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
@@ -265,7 +265,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))}
disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
@@ -284,7 +284,7 @@ export const SavedSignaturesSection = ({
onClick={() => onUseSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
<LocalIcon icon="check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
@@ -294,7 +294,7 @@ export const SavedSignaturesSection = ({
onClick={() => onDeleteSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
@@ -317,7 +317,7 @@ export const SavedSignaturesSection = ({
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:groups-rounded" width={18} height={18} />
<LocalIcon icon="groups-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.sharedHeading', 'Shared Signatures')}
</Text>
@@ -340,7 +340,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActiveSharedIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeSharedIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
@@ -348,7 +348,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))}
disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
@@ -367,7 +367,7 @@ export const SavedSignaturesSection = ({
onClick={() => onUseSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
<LocalIcon icon="check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
{isAdmin && (
<Tooltip label={translate('saved.delete', 'Remove')}>
@@ -378,7 +378,7 @@ export const SavedSignaturesSection = ({
onClick={() => onDeleteSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
)}
@@ -424,7 +424,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeLocalStorageIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
@@ -432,7 +432,7 @@ export const SavedSignaturesSection = ({
onClick={() => setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))}
disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
@@ -451,7 +451,7 @@ export const SavedSignaturesSection = ({
onClick={() => onUseSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
<LocalIcon icon="check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
@@ -461,7 +461,7 @@ export const SavedSignaturesSection = ({
onClick={() => onDeleteSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
@@ -370,7 +370,7 @@ const SignSettings = ({
isReady,
onClick,
'personal',
'material-symbols:person-rounded',
'person-rounded',
translate('saved.savePersonal', 'Save Personal')
);
@@ -379,7 +379,7 @@ const SignSettings = ({
isReady,
onClick,
'shared',
'material-symbols:groups-rounded',
'groups-rounded',
translate('saved.saveShared', 'Save Shared')
);
@@ -488,7 +488,7 @@ const SignSettings = ({
const handleCanvasSignatureChange = useCallback((data: string | null) => {
const nextValue = data ?? undefined;
setCanvasSignatureData(prevData => {
// Reset pause state and trigger placement for signature changes
// Reset pause-rounded state and trigger placement for signature changes
// (onDrawingComplete handles initial activation)
if (prevData && prevData !== nextValue && nextValue) {
setPlacementManuallyPaused(false);
@@ -899,7 +899,7 @@ const SignSettings = ({
color: isPlacementMode ? 'blue' : 'teal',
title: isPlacementMode
? translate('instructions.title', 'How to add your signature')
: translate('instructions.paused', 'Placement paused'),
: translate('instructions.pause-roundedd', 'Placement pause-roundedd'),
message: isPlacementMode
? placementInstructions()
: translate('instructions.resumeHint', 'Resume placement to click and add your signature.'),
@@ -920,7 +920,7 @@ const SignSettings = ({
onActivateSignaturePlacement?.();
};
// Handle Escape key to toggle pause/resume
// Handle Escape key to toggle pause-rounded/resume
useEffect(() => {
if (!isCurrentTypeReady) return;
@@ -943,11 +943,11 @@ const SignSettings = ({
onActivateSignaturePlacement || onDeactivateSignature
? isPlacementMode
? (
<Tooltip label={translate('mode.pause', 'Pause placement')}>
<Tooltip label={translate('mode.pause-rounded', 'Pause placement')}>
<ActionIcon
variant="default"
size="lg"
aria-label={translate('mode.pause', 'Pause placement')}
aria-label={translate('mode.pause-rounded', 'Pause placement')}
onClick={handlePausePlacement}
disabled={disabled || !onDeactivateSignature}
style={{
@@ -958,9 +958,9 @@ const SignSettings = ({
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
<LocalIcon icon="pause-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{translate('mode.pause', 'Pause placement')}
{translate('mode.pause-rounded', 'Pause placement')}
</Text>
</ActionIcon>
</Tooltip>
@@ -981,7 +981,7 @@ const SignSettings = ({
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
<LocalIcon icon="play-arrow-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{translate('mode.resume', 'Resume placement')}
</Text>
@@ -18,6 +18,7 @@ export interface AppConfig {
baseUrl?: string;
contextPath?: string;
serverPort?: number;
frontendUrl?: string;
appNameNavbar?: string;
languages?: string[];
defaultLocale?: string;
@@ -43,6 +44,7 @@ export interface AppConfig {
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
enableMobileScanner?: boolean;
appVersion?: string;
machineType?: string;
activeSecurity?: boolean;
@@ -224,7 +224,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false, //TODO make support Sign
},
addText: {
icon: <LocalIcon icon="material-symbols:text-fields-rounded" width="1.5rem" height="1.5rem" />,
icon: <LocalIcon icon="text-fields-rounded" width="1.5rem" height="1.5rem" />,
name: t('home.addText.title', 'Add Text'),
component: AddText,
description: t('home.addText.desc', 'Add custom text anywhere in your PDF'),
File diff suppressed because it is too large Load Diff
@@ -1250,7 +1250,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:touch-app-rounded" width={20} height={20} />
<LocalIcon icon="touch-app-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{t('annotation.selectAndMove', 'Select and Edit')}
</Text>
+46 -14
View File
@@ -3,11 +3,14 @@ import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -19,24 +22,53 @@ import "@app/styles/auth-theme.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}
@@ -1,7 +1,9 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor } from '@mantine/core';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
@@ -43,10 +45,12 @@ interface ConnectionsSettingsData {
from?: string;
};
ssoAutoLogin?: boolean;
enableMobileScanner?: boolean;
}
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
@@ -65,14 +69,19 @@ export default function AdminConnectionsSection() {
const premiumResponse = await apiClient.get('/api/v1/admin/settings/section/premium');
const premiumData = premiumResponse.data || {};
// Fetch system settings for enableMobileScanner
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
const systemData = systemResponse.data || {};
const result: any = {
oauth2: securityData.oauth2 || {},
saml2: securityData.saml2 || {},
mail: mailData || {},
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
enableMobileScanner: systemData.enableMobileScanner || false
};
// Merge pending blocks from all three endpoints
// Merge pending blocks from all four endpoints
const pendingBlock: any = {};
if (securityData._pending?.oauth2) {
pendingBlock.oauth2 = securityData._pending.oauth2;
@@ -86,6 +95,9 @@ export default function AdminConnectionsSection() {
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
}
if (systemData._pending?.enableMobileScanner !== undefined) {
pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
@@ -331,6 +343,38 @@ export default function AdminConnectionsSection() {
}
};
const handleMobileScannerSave = async (newValue: boolean) => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
const deltaSettings = {
'system.enableMobileScanner': newValue
};
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
if (response.status === 200) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p));
const availableProviders = ALL_PROVIDERS.filter((p) => !isProviderConfigured(p));
@@ -356,7 +400,15 @@ export default function AdminConnectionsSection() {
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin.label', 'SSO Auto Login')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
<Badge
color="grape"
size="sm"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings/adminPlan')}
title={t('admin.settings.badge.clickToUpgrade', 'Click to view plan details')}
>
PRO
</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -383,6 +435,45 @@ export default function AdminConnectionsSection() {
</Stack>
</Paper>
{/* Mobile Scanner (QR Code) Upload */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group gap="xs" align="center">
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" />
<Text fw={600} size="sm">{t('admin.settings.connections.mobileScanner.label', 'Mobile Phone Upload')}</Text>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.mobileScanner.enable', 'Enable QR Code Upload')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.mobileScanner.description', 'Allow users to upload files from mobile devices by scanning a QR code')}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t('admin.settings.connections.mobileScanner.note', 'Note: Requires Frontend URL to be configured. ')}
<Anchor href="#" onClick={(e) => { e.preventDefault(); navigate('/settings/adminGeneral#frontendUrl'); }} c="orange" td="underline">
{t('admin.settings.connections.mobileScanner.link', 'Configure in System Settings')}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableMobileScanner || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
const newValue = e.target.checked;
setSettings({ ...settings, enableMobileScanner: newValue });
handleMobileScannerSave(newValue);
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableMobileScanner')} />
</Group>
</div>
</Stack>
</Paper>
{/* Linked Services Section - Only show if there are linked providers */}
{linkedProviders.length > 0 && (
<>
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Badge } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
@@ -21,6 +22,7 @@ interface FeaturesSettingsData {
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
@@ -118,7 +120,15 @@ export default function AdminFeaturesSection() {
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate.label', 'Server Certificate')}</Text>
<Badge color="blue" size="sm">PRO</Badge>
<Badge
color="grape"
size="sm"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings/adminPlan')}
title={t('admin.settings.badge.clickToUpgrade', 'Click to view plan details')}
>
PRO
</Badge>
</Group>
<Text size="xs" c="dimmed">
@@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
@@ -26,6 +27,7 @@ interface GeneralSettingsData {
showUpdateOnlyAdmin?: boolean;
customHTMLFiles?: boolean;
fileUploadLimit?: string;
frontendUrl?: string;
};
customPaths?: {
pipeline?: {
@@ -47,6 +49,8 @@ interface GeneralSettingsData {
export default function AdminGeneralSection() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { preferences, updatePreference } = usePreferences();
@@ -141,6 +145,7 @@ export default function AdminGeneralSection() {
'system.showUpdateOnlyAdmin': settings.system?.showUpdateOnlyAdmin,
'system.customHTMLFiles': settings.system?.customHTMLFiles,
'system.fileUploadLimit': settings.system?.fileUploadLimit,
'system.frontendUrl': settings.system?.frontendUrl,
// Premium custom metadata
'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata,
'premium.proFeatures.customMetadata.author': settings.customMetadata?.author,
@@ -228,6 +233,19 @@ export default function AdminGeneralSection() {
};
}, [setIsDirty]);
// Handle hash navigation for deep linking to specific fields
useEffect(() => {
if (location.hash && !loading) {
const elementId = location.hash.substring(1); // Remove the #
const element = document.getElementById(elementId);
if (element) {
setTimeout(() => {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
}, [location.hash, loading]);
const handleDiscard = useCallback(() => {
if (originalSettingsSnapshot) {
try {
@@ -441,6 +459,22 @@ export default function AdminGeneralSection() {
/>
</div>
<div id="frontendUrl">
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.frontendUrl.label', 'Frontend URL')}</span>
<PendingBadge show={isFieldPending('system.frontendUrl')} />
</Group>
}
description={t('admin.settings.general.frontendUrl.description', 'Base URL for frontend (e.g., https://pdf.example.com). Used for email invite links and mobile QR code uploads. Leave empty to use backend URL.')}
value={settings.system?.frontendUrl || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, frontendUrl: e.target.value } })}
placeholder="https://pdf.example.com"
disabled={!loginEnabled}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate.label', 'Show Update Notifications')}</Text>
@@ -499,7 +533,15 @@ export default function AdminGeneralSection() {
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.general.customMetadata.label', 'Custom Metadata')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
<Badge
color="grape"
size="sm"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings/adminPlan')}
title={t('admin.settings.badge.clickToUpgrade', 'Click to view plan details')}
>
PRO
</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput, Anchor } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
@@ -17,7 +18,6 @@ interface MailSettingsData {
username?: string;
password?: string;
from?: string;
frontendUrl?: string;
}
interface ApiResponseWithPending<T> {
@@ -25,10 +25,10 @@ interface ApiResponseWithPending<T> {
}
type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsData>;
type SystemApiResponse = { frontendUrl?: string } & ApiResponseWithPending<{ frontendUrl?: string }>;
export default function AdminMailSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@@ -42,44 +42,13 @@ export default function AdminMailSection() {
} = useAdminSettings<MailSettingsData>({
sectionName: 'mail',
fetchTransformer: async () => {
const [mailResponse, systemResponse] = await Promise.all([
apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail'),
apiClient.get<SystemApiResponse>('/api/v1/admin/settings/section/system')
]);
const mail = mailResponse.data || {};
const system = systemResponse.data || {};
const result: MailSettingsData & ApiResponseWithPending<MailSettingsData> = {
...mail,
frontendUrl: system.frontendUrl || ''
};
// Merge pending blocks from both endpoints
const pendingBlock: Partial<MailSettingsData> = {};
if (mail._pending) {
Object.assign(pendingBlock, mail._pending);
}
if (system._pending?.frontendUrl !== undefined) {
pendingBlock.frontendUrl = system._pending.frontendUrl;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
const mailResponse = await apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail');
return mailResponse.data || {};
},
saveTransformer: (settings) => {
const { frontendUrl, ...mailSettings } = settings;
const deltaSettings: Record<string, any> = {
'system.frontendUrl': frontendUrl
};
return {
sectionData: mailSettings,
deltaSettings
sectionData: settings,
deltaSettings: {}
};
}
});
@@ -142,6 +111,12 @@ export default function AdminMailSection() {
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t('admin.settings.mail.frontendUrlNote.note', 'Note: Requires Frontend URL to be configured. ')}
<Anchor href="#" onClick={(e) => { e.preventDefault(); navigate('/settings/adminGeneral#frontendUrl'); }} c="orange" td="underline">
{t('admin.settings.mail.frontendUrlNote.link', 'Configure in System Settings')}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
@@ -226,21 +201,6 @@ export default function AdminMailSection() {
placeholder="[email protected]"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.frontendUrl.label', 'Frontend URL')}</span>
<PendingBadge show={isFieldPending('frontendUrl')} />
</Group>
}
description={t('admin.settings.mail.frontendUrl.description', 'Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL.')}
value={settings.frontendUrl || ''}
onChange={(e) => setSettings({ ...settings, frontendUrl: e.target.value })}
placeholder="https://pdf.example.com"
/>
</div>
</Stack>
</Paper>
+5
View File
@@ -27,6 +27,11 @@ export default defineConfig(({ mode }) => {
//provides static pdfium so embedpdf can run without cdn
src: 'node_modules/@embedpdf/pdfium/dist/pdfium.wasm',
dest: 'pdfium'
},
{
// Copy jscanify vendor files to dist
src: 'public/vendor/jscanify/*',
dest: 'vendor/jscanify'
}
]
})