mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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/samples/
|
||||||
app/core/src/main/resources/static/manifest-classic.json
|
app/core/src/main/resources/static/manifest-classic.json
|
||||||
app/core/src/main/resources/static/robots.txt
|
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.
|
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||||
|
|
||||||
# Gradle
|
# Gradle
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ public class ApplicationProperties {
|
|||||||
private String frontendUrl; // Frontend URL for invite email links (e.g.
|
private String frontendUrl; // Frontend URL for invite email links (e.g.
|
||||||
|
|
||||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
// '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() {
|
public boolean isAnalyticsEnabled() {
|
||||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
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;
|
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
|
// Treat common static file extensions as static resources
|
||||||
return normalizedUri.endsWith(".svg")
|
return normalizedUri.endsWith(".svg")
|
||||||
|| normalizedUri.endsWith(".png")
|
|| normalizedUri.endsWith(".png")
|
||||||
@@ -168,6 +173,8 @@ public class RequestUriUtils {
|
|||||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||||
|
|| trimmedUri.startsWith(
|
||||||
|
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||||
|| trimmedUri.startsWith("/v1/api-docs");
|
|| trimmedUri.startsWith("/v1/api-docs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
|||||||
"principal",
|
"principal",
|
||||||
"startDate",
|
"startDate",
|
||||||
"endDate",
|
"endDate",
|
||||||
"async");
|
"async",
|
||||||
|
"session");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean preHandle(
|
public boolean preHandle(
|
||||||
|
|||||||
+9
@@ -71,6 +71,15 @@ public class ConfigController {
|
|||||||
configData.put("contextPath", appConfig.getContextPath());
|
configData.put("contextPath", appConfig.getContextPath());
|
||||||
configData.put("serverPort", appConfig.getServerPort());
|
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
|
// Extract values from ApplicationProperties
|
||||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||||
|
|||||||
+302
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -115,13 +115,13 @@ public class ReactRoutingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(
|
@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 {
|
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
|
||||||
return serveIndexHtml(request);
|
return serveIndexHtml(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(
|
@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)
|
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
return serveIndexHtml(request);
|
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'
|
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.
|
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.
|
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:
|
serverCertificate:
|
||||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
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--darkmode{
|
||||||
--cc-bg: var(--md-sys-color-inverse-on-surface);
|
--cc-bg: #2d2d2d;
|
||||||
--cc-primary-color: var(--md-sys-color-on-surface);
|
--cc-primary-color: #e5e5e5;
|
||||||
--cc-secondary-color: var(--md-sys-color-on-surface);
|
--cc-secondary-color: #b0b0b0;
|
||||||
|
|
||||||
--cc-btn-primary-bg: var(--md-sys-color-secondary);
|
--cc-btn-primary-bg: #4dabf7;
|
||||||
--cc-btn-primary-color: var(--cc-bg);
|
--cc-btn-primary-color: #ffffff;
|
||||||
--cc-btn-primary-border-color: var(--cc-btn-primary-bg);
|
--cc-btn-primary-border-color: #4dabf7;
|
||||||
--cc-btn-primary-hover-bg: var(--md-sys-color-surface-3);
|
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||||
--cc-btn-primary-hover-color: var(--md-sys-color-on-secondary-container);
|
--cc-btn-primary-hover-color: #ffffff;
|
||||||
--cc-btn-primary-hover-border-color: var(--md-sys-color-surface-3);
|
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||||
|
|
||||||
--cc-btn-secondary-bg: var(--md-sys-color-surface-3);
|
--cc-btn-secondary-bg: #3d3d3d;
|
||||||
--cc-btn-secondary-color: var(--md-sys-color-on-secondary-container);
|
--cc-btn-secondary-color: #ffffff;
|
||||||
--cc-btn-secondary-border-color: var(--md-sys-color-surface-3);
|
--cc-btn-secondary-border-color: #3d3d3d;
|
||||||
--cc-btn-secondary-hover-bg:var(--md-sys-color-secondary);
|
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||||
--cc-btn-secondary-hover-color: var(--cc-bg);
|
--cc-btn-secondary-hover-color: #ffffff;
|
||||||
--cc-btn-secondary-hover-border-color: var(--md-sys-color-secondary);
|
--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-on-bg: #4dabf7;
|
||||||
--cc-toggle-off-bg: var(--md-sys-color-outline);
|
--cc-toggle-off-bg: #667481;
|
||||||
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
|
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||||
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
|
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||||
|
|
||||||
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
|
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||||
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
|
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||||
|
|
||||||
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
|
--cc-toggle-readonly-bg: #555555;
|
||||||
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
|
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||||
--cc-toggle-readonly-knob-icon-color: var(--cc-toggle-readonly-bg);
|
--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-bg: #3d3d3d;
|
||||||
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
|
--cc-cookie-category-block-border: #3d3d3d;
|
||||||
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
|
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||||
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
|
--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-footer-bg: #2d2d2d;
|
||||||
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
|
--cc-footer-color: #e5e5e5;
|
||||||
|
--cc-footer-border-color: #2d2d2d;
|
||||||
/* --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);
|
|
||||||
}
|
}
|
||||||
.cm__body{
|
.cm__body{
|
||||||
max-width: 90% !important;
|
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 */
|
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||||
#cc-main {
|
#cc-main {
|
||||||
z-index: 100 !important;
|
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": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-192x192.png",
|
"src": "modern-logo/favicon.ico",
|
||||||
"sizes": "192x192",
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
"type": "image/png"
|
"type": "image/x-icon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-512x512.png",
|
"src": "modern-logo/logo192.png",
|
||||||
"sizes": "512x512",
|
"type": "image/png",
|
||||||
"type": "image/png"
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "modern-logo/logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "/",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"theme_color": "#000000",
|
||||||
"theme_color": "#000000"
|
"background_color": "#ffffff"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,8 @@
|
|||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="src/index.tsx"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+1151
-2563
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,9 @@
|
|||||||
"license-report": "^6.8.0",
|
"license-report": "^6.8.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.4.149",
|
"pdfjs-dist": "^5.4.149",
|
||||||
|
"peerjs": "^1.5.5",
|
||||||
"posthog-js": "^1.268.0",
|
"posthog-js": "^1.268.0",
|
||||||
|
"qrcode.react": "^4.1.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
|
|||||||
@@ -963,6 +963,7 @@ desc = "Add custom text anywhere in your PDF"
|
|||||||
addFiles = "Add Files"
|
addFiles = "Add Files"
|
||||||
uploadFromComputer = "Upload from computer"
|
uploadFromComputer = "Upload from computer"
|
||||||
openFromComputer = "Open from computer"
|
openFromComputer = "Open from computer"
|
||||||
|
mobileUpload = "Upload from Mobile"
|
||||||
|
|
||||||
[viewPdf]
|
[viewPdf]
|
||||||
tags = "view,read,annotate,text,image,highlight,edit"
|
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"
|
label = "Unoconvert Executable"
|
||||||
description = "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)"
|
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]
|
[admin.settings.security]
|
||||||
title = "Security"
|
title = "Security"
|
||||||
description = "Configure authentication, login behaviour, and security policies."
|
description = "Configure authentication, login behaviour, and security policies."
|
||||||
@@ -4564,6 +4572,13 @@ description = "Automatically create user accounts on first SAML2 login"
|
|||||||
label = "Block Registration"
|
label = "Block Registration"
|
||||||
description = "Prevent new user registration via SAML2"
|
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]
|
[admin.settings.database]
|
||||||
title = "Database"
|
title = "Database"
|
||||||
description = "Configure custom database connection settings for enterprise deployments."
|
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"
|
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."
|
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]
|
[admin.settings.legal]
|
||||||
title = "Legal Documents"
|
title = "Legal Documents"
|
||||||
description = "Configure links to legal documents and policies."
|
description = "Configure links to legal documents and policies."
|
||||||
@@ -4930,6 +4949,8 @@ googleDriveShort = "Drive"
|
|||||||
myFiles = "My Files"
|
myFiles = "My Files"
|
||||||
noRecentFiles = "No recent files found"
|
noRecentFiles = "No recent files found"
|
||||||
googleDriveNotAvailable = "Google Drive integration not available"
|
googleDriveNotAvailable = "Google Drive integration not available"
|
||||||
|
mobileUpload = "Mobile Upload"
|
||||||
|
mobileShort = "Mobile"
|
||||||
downloadSelected = "Download Selected"
|
downloadSelected = "Download Selected"
|
||||||
saveSelected = "Save Selected"
|
saveSelected = "Save Selected"
|
||||||
openFiles = "Open Files"
|
openFiles = "Open Files"
|
||||||
@@ -6399,3 +6420,56 @@ title = "Add Text Results"
|
|||||||
|
|
||||||
[addText.error]
|
[addText.error]
|
||||||
failed = "An error occurred while adding text to the PDF."
|
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
@@ -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;
|
||||||
|
});
|
||||||
+48
File diff suppressed because one or more lines are too long
@@ -1,8 +1,12 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { AppProviders } from "@app/components/AppProviders";
|
import { AppProviders } from "@app/components/AppProviders";
|
||||||
import { AppLayout } from "@app/components/AppLayout";
|
import { AppLayout } from "@app/components/AppLayout";
|
||||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
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 HomePage from "@app/pages/HomePage";
|
||||||
|
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||||
|
|
||||||
// Import global styles
|
// Import global styles
|
||||||
@@ -13,15 +17,44 @@ import "@app/styles/index.css";
|
|||||||
// Import file ID debugging helpers (development only)
|
// Import file ID debugging helpers (development only)
|
||||||
import "@app/utils/fileIdSafety";
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<AppProviders>
|
<Routes>
|
||||||
<AppLayout>
|
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
|
||||||
<HomePage />
|
<Route
|
||||||
<Onboarding />
|
path="/mobile-scanner"
|
||||||
</AppLayout>
|
element={
|
||||||
</AppProviders>
|
<MobileScannerProviders>
|
||||||
|
<MobileScannerPage />
|
||||||
|
</MobileScannerProviders>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* All other routes need AppProviders for backend integration */}
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<AppProviders>
|
||||||
|
<AppLayout>
|
||||||
|
<HomePage />
|
||||||
|
<Onboarding />
|
||||||
|
</AppLayout>
|
||||||
|
</AppProviders>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Stack, Text, Button, Group } from '@mantine/core';
|
import { Stack, Text, Button, Group } from '@mantine/core';
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
import CloudIcon from '@mui/icons-material/Cloud';
|
import CloudIcon from '@mui/icons-material/Cloud';
|
||||||
|
import PhonelinkIcon from '@mui/icons-material/Phonelink';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||||
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||||
|
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||||
|
|
||||||
interface FileSourceButtonsProps {
|
interface FileSourceButtonsProps {
|
||||||
horizontal?: boolean;
|
horizontal?: boolean;
|
||||||
@@ -15,12 +17,13 @@ interface FileSourceButtonsProps {
|
|||||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||||
horizontal = false
|
horizontal = false
|
||||||
}) => {
|
}) => {
|
||||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
|
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||||
const terminology = useFileActionTerminology();
|
const terminology = useFileActionTerminology();
|
||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
const UploadIcon = icons.upload;
|
const UploadIcon = icons.upload;
|
||||||
|
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleGoogleDriveClick = async () => {
|
const handleGoogleDriveClick = async () => {
|
||||||
try {
|
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 = {
|
const buttonProps = {
|
||||||
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||||
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
|
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')}
|
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||||
</Button>
|
</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) {
|
if (horizontal) {
|
||||||
return (
|
return (
|
||||||
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
<>
|
||||||
{buttons}
|
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
||||||
</Group>
|
{buttons}
|
||||||
|
</Group>
|
||||||
|
<MobileUploadModal
|
||||||
|
opened={mobileUploadModalOpen}
|
||||||
|
onClose={() => setMobileUploadModalOpen(false)}
|
||||||
|
onFilesReceived={handleFilesReceivedFromMobile}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" style={{ height: '100%' }}>
|
<>
|
||||||
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
<Stack gap="xs" style={{ height: '100%' }}>
|
||||||
{t('fileManager.myFiles', 'My Files')}
|
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
||||||
</Text>
|
{t('fileManager.myFiles', 'My Files')}
|
||||||
{buttons}
|
</Text>
|
||||||
</Stack>
|
{buttons}
|
||||||
|
</Stack>
|
||||||
|
<MobileUploadModal
|
||||||
|
opened={mobileUploadModalOpen}
|
||||||
|
onClose={() => setMobileUploadModalOpen(false)}
|
||||||
|
onFilesReceived={handleFilesReceivedFromMobile}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
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 { Dropzone } from '@mantine/dropzone';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -11,6 +11,9 @@ import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
|||||||
import { useFileManager } from '@app/hooks/useFileManager';
|
import { useFileManager } from '@app/hooks/useFileManager';
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
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 LandingPage = () => {
|
||||||
const { addFiles } = useFileHandler();
|
const { addFiles } = useFileHandler();
|
||||||
@@ -24,8 +27,11 @@ const LandingPage = () => {
|
|||||||
const { wordmark } = useLogoAssets();
|
const { wordmark } = useLogoAssets();
|
||||||
const { loadRecentFiles } = useFileManager();
|
const { loadRecentFiles } = useFileManager();
|
||||||
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
|
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
|
||||||
|
const [mobileUploadModalOpen, setMobileUploadModalOpen] = React.useState(false);
|
||||||
const terminology = useFileActionTerminology();
|
const terminology = useFileActionTerminology();
|
||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const handleFileDrop = async (files: File[]) => {
|
const handleFileDrop = async (files: File[]) => {
|
||||||
await addFiles(files);
|
await addFiles(files);
|
||||||
@@ -48,6 +54,16 @@ const LandingPage = () => {
|
|||||||
event.target.value = '';
|
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)
|
// Determine if the user has any recent files (same source as File Manager)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -202,32 +218,72 @@ const LandingPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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 && (
|
{!hasRecents && (
|
||||||
<Button
|
<>
|
||||||
aria-label="Upload"
|
<Button
|
||||||
style={{
|
aria-label="Upload"
|
||||||
backgroundColor: 'var(--landing-button-bg)',
|
style={{
|
||||||
color: 'var(--landing-button-color)',
|
backgroundColor: 'var(--landing-button-bg)',
|
||||||
border: '1px solid var(--landing-button-border)',
|
color: 'var(--landing-button-color)',
|
||||||
borderRadius: '1rem',
|
border: '1px solid var(--landing-button-border)',
|
||||||
height: '38px',
|
borderRadius: '1rem',
|
||||||
width: '100%',
|
height: '38px',
|
||||||
minWidth: '58px',
|
width: 'calc(100% - 38px - 0.6rem)',
|
||||||
paddingLeft: '1rem',
|
minWidth: '58px',
|
||||||
paddingRight: '1rem',
|
paddingLeft: '1rem',
|
||||||
display: 'flex',
|
paddingRight: '1rem',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
justifyContent: 'center'
|
alignItems: 'center',
|
||||||
}}
|
justifyContent: 'center'
|
||||||
onClick={handleNativeUploadClick}
|
}}
|
||||||
>
|
onClick={handleNativeUploadClick}
|
||||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
>
|
||||||
<span style={{ marginLeft: '.5rem' }}>
|
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
<span style={{ marginLeft: '.5rem' }}>
|
||||||
</span>
|
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -251,6 +307,11 @@ const LandingPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
<MobileUploadModal
|
||||||
|
opened={mobileUploadModalOpen}
|
||||||
|
onClose={() => setMobileUploadModalOpen(false)}
|
||||||
|
onFilesReceived={handleFilesReceivedFromMobile}
|
||||||
|
/>
|
||||||
</Container>
|
</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 (
|
return (
|
||||||
<Tooltip
|
<React.Fragment key={buttonConfig.id}>
|
||||||
position="right"
|
<Tooltip
|
||||||
arrow
|
position="right"
|
||||||
offset={8}
|
arrow
|
||||||
open={tooltipOpen}
|
offset={8}
|
||||||
manualCloseOnly={manualCloseOnly}
|
open={tooltipOpen}
|
||||||
showCloseButton={showCloseButton}
|
manualCloseOnly={manualCloseOnly}
|
||||||
closeOnOutside={false}
|
showCloseButton={showCloseButton}
|
||||||
openOnFocus={false}
|
closeOnOutside={false}
|
||||||
content={toursTooltipContent}
|
openOnFocus={false}
|
||||||
onOpenChange={handleTooltipOpenChange}
|
content={toursTooltipContent}
|
||||||
>
|
onOpenChange={handleTooltipOpenChange}
|
||||||
{helpButtonNode}
|
>
|
||||||
</Tooltip>
|
{helpButtonNode}
|
||||||
|
</Tooltip>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<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')}
|
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
|
||||||
color="blue"
|
color="blue"
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
|||||||
size="sm"
|
size="sm"
|
||||||
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
|
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
|
||||||
onClick={() => setUpdateModalOpened(true)}
|
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')}
|
{t('settings.general.updates.viewDetails', 'View Details')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ function getDefaultIconName(t: ToastInstance): string {
|
|||||||
case 'success':
|
case 'success':
|
||||||
return 'check-circle-rounded';
|
return 'check-circle-rounded';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'close-rounded';
|
return 'cancel';
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return 'warning-rounded';
|
return 'warning';
|
||||||
case 'neutral':
|
case 'neutral':
|
||||||
default:
|
default:
|
||||||
return 'info-rounded';
|
return 'info';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export default function ToastRenderer() {
|
|||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="toast-icon">
|
<div className="toast-icon">
|
||||||
{t.icon ?? (
|
{t.icon ?? (
|
||||||
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
|
<LocalIcon icon={getDefaultIconName(t)} width={20} height={20} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export default function ToastRenderer() {
|
|||||||
}}
|
}}
|
||||||
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -94,7 +94,7 @@ export default function ToastRenderer() {
|
|||||||
onClick={() => dismiss(t.id)}
|
onClick={() => dismiss(t.id)}
|
||||||
className="toast-button"
|
className="toast-button"
|
||||||
>
|
>
|
||||||
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
|
<LocalIcon icon="close" width={20} height={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals
|
|||||||
component="label"
|
component="label"
|
||||||
htmlFor="attachments-input"
|
htmlFor="attachments-input"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
|
leftSection={<LocalIcon icon="add" width="14" height="14" />}
|
||||||
>
|
>
|
||||||
{parameters.attachments?.length > 0
|
{parameters.attachments?.length > 0
|
||||||
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
|
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export const SavedSignaturesSection = ({
|
|||||||
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
|
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group 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">
|
<Text fw={600} size="sm">
|
||||||
{translate('saved.personalHeading', 'Personal Signatures')}
|
{translate('saved.personalHeading', 'Personal Signatures')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -257,7 +257,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => setActivePersonalIndex(prev => Math.max(0, prev - 1))}
|
onClick={() => setActivePersonalIndex(prev => Math.max(0, prev - 1))}
|
||||||
disabled={disabled || activePersonalIndex === 0}
|
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>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -265,7 +265,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))}
|
onClick={() => setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))}
|
||||||
disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 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>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -284,7 +284,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => onUseSignature(activePersonalSignature)}
|
onClick={() => onUseSignature(activePersonalSignature)}
|
||||||
disabled={disabled}
|
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>
|
</ActionIcon>
|
||||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -294,7 +294,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => onDeleteSignature(activePersonalSignature)}
|
onClick={() => onDeleteSignature(activePersonalSignature)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -317,7 +317,7 @@ export const SavedSignaturesSection = ({
|
|||||||
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
|
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group 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">
|
<Text fw={600} size="sm">
|
||||||
{translate('saved.sharedHeading', 'Shared Signatures')}
|
{translate('saved.sharedHeading', 'Shared Signatures')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -340,7 +340,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => setActiveSharedIndex(prev => Math.max(0, prev - 1))}
|
onClick={() => setActiveSharedIndex(prev => Math.max(0, prev - 1))}
|
||||||
disabled={disabled || activeSharedIndex === 0}
|
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>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -348,7 +348,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))}
|
onClick={() => setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))}
|
||||||
disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 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>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -367,7 +367,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => onUseSignature(activeSharedSignature)}
|
onClick={() => onUseSignature(activeSharedSignature)}
|
||||||
disabled={disabled}
|
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>
|
</ActionIcon>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||||
@@ -378,7 +378,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => onDeleteSignature(activeSharedSignature)}
|
onClick={() => onDeleteSignature(activeSharedSignature)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -424,7 +424,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))}
|
onClick={() => setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))}
|
||||||
disabled={disabled || activeLocalStorageIndex === 0}
|
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>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -432,7 +432,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))}
|
onClick={() => setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))}
|
||||||
disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 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>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -451,7 +451,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => onUseSignature(activeLocalStorageSignature)}
|
onClick={() => onUseSignature(activeLocalStorageSignature)}
|
||||||
disabled={disabled}
|
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>
|
</ActionIcon>
|
||||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -461,7 +461,7 @@ export const SavedSignaturesSection = ({
|
|||||||
onClick={() => onDeleteSignature(activeLocalStorageSignature)}
|
onClick={() => onDeleteSignature(activeLocalStorageSignature)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ const SignSettings = ({
|
|||||||
isReady,
|
isReady,
|
||||||
onClick,
|
onClick,
|
||||||
'personal',
|
'personal',
|
||||||
'material-symbols:person-rounded',
|
'person-rounded',
|
||||||
translate('saved.savePersonal', 'Save Personal')
|
translate('saved.savePersonal', 'Save Personal')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ const SignSettings = ({
|
|||||||
isReady,
|
isReady,
|
||||||
onClick,
|
onClick,
|
||||||
'shared',
|
'shared',
|
||||||
'material-symbols:groups-rounded',
|
'groups-rounded',
|
||||||
translate('saved.saveShared', 'Save Shared')
|
translate('saved.saveShared', 'Save Shared')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -488,7 +488,7 @@ const SignSettings = ({
|
|||||||
const handleCanvasSignatureChange = useCallback((data: string | null) => {
|
const handleCanvasSignatureChange = useCallback((data: string | null) => {
|
||||||
const nextValue = data ?? undefined;
|
const nextValue = data ?? undefined;
|
||||||
setCanvasSignatureData(prevData => {
|
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)
|
// (onDrawingComplete handles initial activation)
|
||||||
if (prevData && prevData !== nextValue && nextValue) {
|
if (prevData && prevData !== nextValue && nextValue) {
|
||||||
setPlacementManuallyPaused(false);
|
setPlacementManuallyPaused(false);
|
||||||
@@ -899,7 +899,7 @@ const SignSettings = ({
|
|||||||
color: isPlacementMode ? 'blue' : 'teal',
|
color: isPlacementMode ? 'blue' : 'teal',
|
||||||
title: isPlacementMode
|
title: isPlacementMode
|
||||||
? translate('instructions.title', 'How to add your signature')
|
? translate('instructions.title', 'How to add your signature')
|
||||||
: translate('instructions.paused', 'Placement paused'),
|
: translate('instructions.pause-roundedd', 'Placement pause-roundedd'),
|
||||||
message: isPlacementMode
|
message: isPlacementMode
|
||||||
? placementInstructions()
|
? placementInstructions()
|
||||||
: translate('instructions.resumeHint', 'Resume placement to click and add your signature.'),
|
: translate('instructions.resumeHint', 'Resume placement to click and add your signature.'),
|
||||||
@@ -920,7 +920,7 @@ const SignSettings = ({
|
|||||||
onActivateSignaturePlacement?.();
|
onActivateSignaturePlacement?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle Escape key to toggle pause/resume
|
// Handle Escape key to toggle pause-rounded/resume
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCurrentTypeReady) return;
|
if (!isCurrentTypeReady) return;
|
||||||
|
|
||||||
@@ -943,11 +943,11 @@ const SignSettings = ({
|
|||||||
onActivateSignaturePlacement || onDeactivateSignature
|
onActivateSignaturePlacement || onDeactivateSignature
|
||||||
? isPlacementMode
|
? isPlacementMode
|
||||||
? (
|
? (
|
||||||
<Tooltip label={translate('mode.pause', 'Pause placement')}>
|
<Tooltip label={translate('mode.pause-rounded', 'Pause placement')}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={translate('mode.pause', 'Pause placement')}
|
aria-label={translate('mode.pause-rounded', 'Pause placement')}
|
||||||
onClick={handlePausePlacement}
|
onClick={handlePausePlacement}
|
||||||
disabled={disabled || !onDeactivateSignature}
|
disabled={disabled || !onDeactivateSignature}
|
||||||
style={{
|
style={{
|
||||||
@@ -958,9 +958,9 @@ const SignSettings = ({
|
|||||||
gap: '0.4rem',
|
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}>
|
<Text component="span" size="sm" fw={500}>
|
||||||
{translate('mode.pause', 'Pause placement')}
|
{translate('mode.pause-rounded', 'Pause placement')}
|
||||||
</Text>
|
</Text>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -981,7 +981,7 @@ const SignSettings = ({
|
|||||||
gap: '0.4rem',
|
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}>
|
<Text component="span" size="sm" fw={500}>
|
||||||
{translate('mode.resume', 'Resume placement')}
|
{translate('mode.resume', 'Resume placement')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface AppConfig {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
contextPath?: string;
|
contextPath?: string;
|
||||||
serverPort?: number;
|
serverPort?: number;
|
||||||
|
frontendUrl?: string;
|
||||||
appNameNavbar?: string;
|
appNameNavbar?: string;
|
||||||
languages?: string[];
|
languages?: string[];
|
||||||
defaultLocale?: string;
|
defaultLocale?: string;
|
||||||
@@ -43,6 +44,7 @@ export interface AppConfig {
|
|||||||
license?: string;
|
license?: string;
|
||||||
SSOAutoLogin?: boolean;
|
SSOAutoLogin?: boolean;
|
||||||
serverCertificateEnabled?: boolean;
|
serverCertificateEnabled?: boolean;
|
||||||
|
enableMobileScanner?: boolean;
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
machineType?: string;
|
machineType?: string;
|
||||||
activeSecurity?: boolean;
|
activeSecurity?: boolean;
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
|||||||
supportsAutomate: false, //TODO make support Sign
|
supportsAutomate: false, //TODO make support Sign
|
||||||
},
|
},
|
||||||
addText: {
|
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'),
|
name: t('home.addText.title', 'Add Text'),
|
||||||
component: AddText,
|
component: AddText,
|
||||||
description: t('home.addText.desc', 'Add custom text anywhere in your PDF'),
|
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',
|
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}>
|
<Text component="span" size="sm" fw={500}>
|
||||||
{t('annotation.selectAndMove', 'Select and Edit')}
|
{t('annotation.selectAndMove', 'Select and Edit')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { Routes, Route } from "react-router-dom";
|
|||||||
import { AppProviders } from "@app/components/AppProviders";
|
import { AppProviders } from "@app/components/AppProviders";
|
||||||
import { AppLayout } from "@app/components/AppLayout";
|
import { AppLayout } from "@app/components/AppLayout";
|
||||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
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 Landing from "@app/routes/Landing";
|
||||||
import Login from "@app/routes/Login";
|
import Login from "@app/routes/Login";
|
||||||
import Signup from "@app/routes/Signup";
|
import Signup from "@app/routes/Signup";
|
||||||
import AuthCallback from "@app/routes/AuthCallback";
|
import AuthCallback from "@app/routes/AuthCallback";
|
||||||
import InviteAccept from "@app/routes/InviteAccept";
|
import InviteAccept from "@app/routes/InviteAccept";
|
||||||
|
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||||
|
|
||||||
// Import global styles
|
// Import global styles
|
||||||
@@ -19,24 +22,53 @@ import "@app/styles/auth-theme.css";
|
|||||||
// Import file ID debugging helpers (development only)
|
// Import file ID debugging helpers (development only)
|
||||||
import "@app/utils/fileIdSafety";
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<AppProviders>
|
<Routes>
|
||||||
<AppLayout>
|
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
|
||||||
<Routes>
|
<Route
|
||||||
{/* Auth routes - no nested providers needed */}
|
path="/mobile-scanner"
|
||||||
<Route path="/login" element={<Login />} />
|
element={
|
||||||
<Route path="/signup" element={<Signup />} />
|
<MobileScannerProviders>
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<MobileScannerPage />
|
||||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
</MobileScannerProviders>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main app routes - Landing handles auth logic */}
|
{/* All other routes need AppProviders for backend integration */}
|
||||||
<Route path="/*" element={<Landing />} />
|
<Route
|
||||||
</Routes>
|
path="*"
|
||||||
<Onboarding />
|
element={
|
||||||
</AppLayout>
|
<AppProviders>
|
||||||
</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>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-4
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { alert } from '@app/components/toast';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
@@ -43,10 +45,12 @@ interface ConnectionsSettingsData {
|
|||||||
from?: string;
|
from?: string;
|
||||||
};
|
};
|
||||||
ssoAutoLogin?: boolean;
|
ssoAutoLogin?: boolean;
|
||||||
|
enableMobileScanner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminConnectionsSection() {
|
export default function AdminConnectionsSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
|
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
|
||||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
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 premiumResponse = await apiClient.get('/api/v1/admin/settings/section/premium');
|
||||||
const premiumData = premiumResponse.data || {};
|
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 = {
|
const result: any = {
|
||||||
oauth2: securityData.oauth2 || {},
|
oauth2: securityData.oauth2 || {},
|
||||||
saml2: securityData.saml2 || {},
|
saml2: securityData.saml2 || {},
|
||||||
mail: mailData || {},
|
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 = {};
|
const pendingBlock: any = {};
|
||||||
if (securityData._pending?.oauth2) {
|
if (securityData._pending?.oauth2) {
|
||||||
pendingBlock.oauth2 = securityData._pending.oauth2;
|
pendingBlock.oauth2 = securityData._pending.oauth2;
|
||||||
@@ -86,6 +95,9 @@ export default function AdminConnectionsSection() {
|
|||||||
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
|
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
|
||||||
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
|
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
|
||||||
}
|
}
|
||||||
|
if (systemData._pending?.enableMobileScanner !== undefined) {
|
||||||
|
pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(pendingBlock).length > 0) {
|
if (Object.keys(pendingBlock).length > 0) {
|
||||||
result._pending = pendingBlock;
|
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 linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p));
|
||||||
const availableProviders = 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">
|
<Stack gap="md">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin.label', 'SSO Auto Login')}</Text>
|
<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>
|
</Group>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
@@ -383,6 +435,45 @@ export default function AdminConnectionsSection() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</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 */}
|
{/* Linked Services Section - Only show if there are linked providers */}
|
||||||
{linkedProviders.length > 0 && (
|
{linkedProviders.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Badge } from '@mantine/core';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
@@ -21,6 +22,7 @@ interface FeaturesSettingsData {
|
|||||||
|
|
||||||
export default function AdminFeaturesSection() {
|
export default function AdminFeaturesSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
|
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
|
||||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||||
|
|
||||||
@@ -118,7 +120,15 @@ export default function AdminFeaturesSection() {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate.label', 'Server Certificate')}</Text>
|
<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>
|
</Group>
|
||||||
|
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
|
|||||||
+43
-1
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
@@ -26,6 +27,7 @@ interface GeneralSettingsData {
|
|||||||
showUpdateOnlyAdmin?: boolean;
|
showUpdateOnlyAdmin?: boolean;
|
||||||
customHTMLFiles?: boolean;
|
customHTMLFiles?: boolean;
|
||||||
fileUploadLimit?: string;
|
fileUploadLimit?: string;
|
||||||
|
frontendUrl?: string;
|
||||||
};
|
};
|
||||||
customPaths?: {
|
customPaths?: {
|
||||||
pipeline?: {
|
pipeline?: {
|
||||||
@@ -47,6 +49,8 @@ interface GeneralSettingsData {
|
|||||||
|
|
||||||
export default function AdminGeneralSection() {
|
export default function AdminGeneralSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
||||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||||
const { preferences, updatePreference } = usePreferences();
|
const { preferences, updatePreference } = usePreferences();
|
||||||
@@ -141,6 +145,7 @@ export default function AdminGeneralSection() {
|
|||||||
'system.showUpdateOnlyAdmin': settings.system?.showUpdateOnlyAdmin,
|
'system.showUpdateOnlyAdmin': settings.system?.showUpdateOnlyAdmin,
|
||||||
'system.customHTMLFiles': settings.system?.customHTMLFiles,
|
'system.customHTMLFiles': settings.system?.customHTMLFiles,
|
||||||
'system.fileUploadLimit': settings.system?.fileUploadLimit,
|
'system.fileUploadLimit': settings.system?.fileUploadLimit,
|
||||||
|
'system.frontendUrl': settings.system?.frontendUrl,
|
||||||
// Premium custom metadata
|
// Premium custom metadata
|
||||||
'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata,
|
'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata,
|
||||||
'premium.proFeatures.customMetadata.author': settings.customMetadata?.author,
|
'premium.proFeatures.customMetadata.author': settings.customMetadata?.author,
|
||||||
@@ -228,6 +233,19 @@ export default function AdminGeneralSection() {
|
|||||||
};
|
};
|
||||||
}, [setIsDirty]);
|
}, [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(() => {
|
const handleDiscard = useCallback(() => {
|
||||||
if (originalSettingsSnapshot) {
|
if (originalSettingsSnapshot) {
|
||||||
try {
|
try {
|
||||||
@@ -441,6 +459,22 @@ export default function AdminGeneralSection() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<div>
|
<div>
|
||||||
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate.label', 'Show Update Notifications')}</Text>
|
<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">
|
<Stack gap="md">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text fw={600} size="sm">{t('admin.settings.general.customMetadata.label', 'Custom Metadata')}</Text>
|
<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>
|
</Group>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
|||||||
+13
-53
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
@@ -17,7 +18,6 @@ interface MailSettingsData {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
frontendUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponseWithPending<T> {
|
interface ApiResponseWithPending<T> {
|
||||||
@@ -25,10 +25,10 @@ interface ApiResponseWithPending<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsData>;
|
type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsData>;
|
||||||
type SystemApiResponse = { frontendUrl?: string } & ApiResponseWithPending<{ frontendUrl?: string }>;
|
|
||||||
|
|
||||||
export default function AdminMailSection() {
|
export default function AdminMailSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -42,44 +42,13 @@ export default function AdminMailSection() {
|
|||||||
} = useAdminSettings<MailSettingsData>({
|
} = useAdminSettings<MailSettingsData>({
|
||||||
sectionName: 'mail',
|
sectionName: 'mail',
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async () => {
|
||||||
const [mailResponse, systemResponse] = await Promise.all([
|
const mailResponse = await apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail');
|
||||||
apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail'),
|
return mailResponse.data || {};
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
saveTransformer: (settings) => {
|
saveTransformer: (settings) => {
|
||||||
const { frontendUrl, ...mailSettings } = settings;
|
|
||||||
|
|
||||||
const deltaSettings: Record<string, any> = {
|
|
||||||
'system.frontendUrl': frontendUrl
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sectionData: mailSettings,
|
sectionData: settings,
|
||||||
deltaSettings
|
deltaSettings: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -142,6 +111,12 @@ export default function AdminMailSection() {
|
|||||||
<Text size="xs" c="dimmed" mt={4}>
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
|
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
|
||||||
</Text>
|
</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>
|
</div>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -226,21 +201,6 @@ export default function AdminMailSection() {
|
|||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
//provides static pdfium so embedpdf can run without cdn
|
//provides static pdfium so embedpdf can run without cdn
|
||||||
src: 'node_modules/@embedpdf/pdfium/dist/pdfium.wasm',
|
src: 'node_modules/@embedpdf/pdfium/dist/pdfium.wasm',
|
||||||
dest: 'pdfium'
|
dest: 'pdfium'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Copy jscanify vendor files to dist
|
||||||
|
src: 'public/vendor/jscanify/*',
|
||||||
|
dest: 'vendor/jscanify'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user