diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index a9369519e5..a8bc2812b7 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -1046,6 +1046,8 @@ public class ApplicationProperties { // 'https://app.example.com'). If not set, falls back to backendUrl. private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature + private boolean enableMobileSignature = + true; // Enable drawing signatures on a phone via QR code private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings(); private ServerCertificate serverCertificate = new ServerCertificate(); diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index a653ae6c0b..f85880df5d 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -56,8 +56,10 @@ public class RequestUriUtils { return true; } - // Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed) - if (normalizedUri.startsWith("/mobile-scanner")) { + // Mobile pages reached by scanning a QR code (peer-to-peer, no backend auth + // needed): /mobile-scanner uploads photos, /mobile-sign draws a signature. + if (normalizedUri.startsWith("/mobile-scanner") + || normalizedUri.startsWith("/mobile-sign")) { return true; } diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 1912f3808b..0e399c1fae 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -73,6 +73,13 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner")); } + @Test + void testIsStaticResource_mobileSignPath() { + // The phone-side signature drawing page, reached from the Sign tool QR code. + assertTrue(RequestUriUtils.isStaticResource("/mobile-sign")); + assertTrue(RequestUriUtils.isStaticResource("/app", "/app/mobile-sign")); + } + @Test void testIsStaticResource_portalShell() { // The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable. diff --git a/app/core/build.gradle b/app/core/build.gradle index 5e90672f75..dea6d0d6b4 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -312,8 +312,9 @@ tasks.register('cleanFrontendAssets', Delete) { delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) } // Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are // copied from the frontend build. Remove stale ones so renamed/removed tools don't linger. - // api-landing.html and mobile-upload.html are real backend source files, not generated artifacts. - delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html']) + // api-landing.html, mobile-upload.html and mobile-sign.html are real backend source files, + // not generated artifacts. + delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html', 'mobile-sign.html']) // Nested prerendered route pages (e.g. settings/people.html) delete new File(resourcesStaticDir, 'settings') } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 8044050025..36beb6610c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -194,6 +194,9 @@ public class ConfigController { configData.put( "enableMobileScanner", applicationProperties.getSystem().isEnableMobileScanner()); + configData.put( + "enableMobileSignature", + applicationProperties.getSystem().isEnableMobileSignature()); configData.put( "mobileScannerConvertToPdf", applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf()); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java index c04911ee57..0bfa6c0337 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java @@ -62,12 +62,17 @@ public class MobileScannerController { } /** - * Check if mobile scanner feature is enabled + * Check if any feature backed by these transfer sessions is enabled. The mobile scanner and + * mobile signature drawing share this session/upload API, so the endpoints stay available while + * either feature is on; each flag independently controls only its own UI. * * @return Error response if disabled, null if enabled */ private ResponseEntity> checkFeatureEnabled() { - if (!applicationProperties.getSystem().isEnableMobileScanner()) { + boolean anyEnabled = + applicationProperties.getSystem().isEnableMobileScanner() + || applicationProperties.getSystem().isEnableMobileSignature(); + if (!anyEnabled) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body( Map.of( @@ -275,7 +280,8 @@ public class MobileScannerController { @Parameter(description = "Filename to download", required = true) @PathVariable String filename) { - if (!applicationProperties.getSystem().isEnableMobileScanner()) { + if (!applicationProperties.getSystem().isEnableMobileScanner() + && !applicationProperties.getSystem().isEnableMobileSignature()) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 7689d109fe..89d4bb9956 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -80,6 +80,8 @@ public class ReactRoutingController { private boolean saasLandingExists = false; private String cachedMobileUploadHtml; private boolean mobileUploadHtmlExists = false; + private String cachedMobileSignHtml; + private boolean mobileSignHtmlExists = false; @PostConstruct public void init() { @@ -103,10 +105,12 @@ public class ReactRoutingController { } // Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't - // load the React /mobile-scanner route from the local backend. Cache the self-contained - // static upload page to serve at that route in desktop mode instead. + // load the React /mobile-scanner or /mobile-sign routes from the local backend. Cache the + // self-contained static pages to serve at those routes in desktop mode instead. this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html"); this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null; + this.cachedMobileSignHtml = readStaticHtml("mobile-sign.html"); + this.mobileSignHtmlExists = this.cachedMobileSignHtml != null; // Check for external index.html first (customFiles/static/) Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html"); @@ -268,6 +272,17 @@ public class ReactRoutingController { return serveIndexHtml(request); } + @GetMapping(value = "/mobile-sign", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity serveMobileSign(HttpServletRequest request) { + if (isDesktopMode() && mobileSignHtmlExists) { + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().mustRevalidate()) + .contentType(MediaType.TEXT_HTML) + .body(cachedMobileSignHtml); + } + return serveIndexHtml(request); + } + @GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE) public ResponseEntity serveTauriAuthCallback(HttpServletRequest request) { // cachedCallbackHtml is always initialized in @PostConstruct diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 4f7f62e084..d0234a3594 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -188,6 +188,7 @@ system: backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured. + enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured. mobileScannerSettings: convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is. imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true. diff --git a/app/core/src/main/resources/static/mobile-sign.html b/app/core/src/main/resources/static/mobile-sign.html new file mode 100644 index 0000000000..9f0204c4a9 --- /dev/null +++ b/app/core/src/main/resources/static/mobile-sign.html @@ -0,0 +1,642 @@ + + + + + + + + Stirling PDF - Draw Signature + + + + + + + + +
+
+ +
+
Stirling PDF
+
Draw Signature
+
+
+ +
+
Connecting…
+ +
+
+ +
+ +
+
+ + +
+
+ + + +
+
+ + +
+
+ + + + + +

Draw your signature above, then send it. It appears in the Sign tool on your computer automatically.

+
+ + + +
Stirling PDF · signatures transfer directly to your desktop
+
+ + + + diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java index bfacb4d503..dc9c9538cd 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java @@ -49,6 +49,33 @@ class MobileScannerControllerTest { when(systemProps.isEnableMobileScanner()).thenReturn(false); } + // --- shared-endpoint gating: scanner and mobile signature share this API --- + + @Test + void createSession_whenOnlyMobileSignatureEnabled_returnsOk() { + // The signature feature must work with the scanner switched off. + when(applicationProperties.getSystem()).thenReturn(systemProps); + when(systemProps.isEnableMobileScanner()).thenReturn(false); + when(systemProps.isEnableMobileSignature()).thenReturn(true); + SessionInfo sessionInfo = new SessionInfo("test-session", 1000L, 601000L, 600000L); + when(mobileScannerService.createSession("test-session")).thenReturn(sessionInfo); + + ResponseEntity> response = controller.createSession("test-session"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + void createSession_whenBothFeaturesDisabled_returnsForbidden() { + when(applicationProperties.getSystem()).thenReturn(systemProps); + when(systemProps.isEnableMobileScanner()).thenReturn(false); + when(systemProps.isEnableMobileSignature()).thenReturn(false); + + ResponseEntity> response = controller.createSession("test-session"); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + } + // --- createSession tests --- @Test diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index ed1b61e098..8ce96c2552 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4993,6 +4993,34 @@ uploadSuccess = "Upload Successful!" uploadSuccessMessage = "Your images have been transferred." validating = "Validating session..." +[mobileSign] +clear = "Clear" +invalidSession = "Session expired" +invalidSessionMessage = "This QR code is no longer valid. Open the Sign tool on your computer and scan the new code." +penSizeLabel = "Pen size" +send = "Send to computer" +sendAnother = "Send another signature" +sendError = "Could not send the signature. Check the connection and try again." +sentMessage = "Signature sent to your computer. You can send another or close this page." +tabsLabel = "Signature source" +undo = "Undo" +validating = "Checking session…" + +[mobileSign.photo] +fromGallery = "From gallery" +hint = "Photograph a signature on white paper, or choose an existing image." +invalidType = "Please choose an image file." +takePhoto = "Take a photo" + +[mobileSign.tab] +draw = "Draw" +photo = "Photo" +type = "Type" + +[mobileSign.type] +placeholder = "Your name" +previewPlaceholder = "Signature preview" + [mobileUpload] description = "Scan to upload photos. Images auto-convert to PDF." descriptionNoConvert = "Scan to upload photos from your mobile device." @@ -10254,6 +10282,7 @@ backgroundRemovalFailedTitle = "Background removal failed" hint = "Upload a PNG or JPG image of your signature" label = "Upload signature image" placeholder = "Select image file" +previewAlt = "Current image signature" processing = "Processing image..." removeBackground = "Remove white background (make transparent)" @@ -10267,6 +10296,17 @@ saved = "Select a saved signature above, then click anywhere on the PDF to place text = "After entering your name above, click anywhere on the PDF to place your signature." title = "How to add signature" +[sign.mobile] +createFromPhone = "Mobile upload" +description = "Scan this QR code with your phone or tablet, draw your signature, and it will appear here automatically." +error = "Connection Error" +expiryWarning = "QR Code Expiring Soon" +expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically." +instructions = "Open the camera app on your phone and scan this code. Keep this window open while you draw." +pollingError = "Error checking for the signature" +sessionCreateError = "Failed to create session" +title = "Draw on your phone" + [sign.mode] move = "Move Signature" pause = "Pause placement" diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 6fd23e5510..81db0564b8 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -9,6 +9,7 @@ import HomePage from "@app/pages/HomePage"; import Onboarding from "@app/components/onboarding/Onboarding"; const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); // Import global styles import "@app/styles/tailwind.css"; @@ -42,6 +43,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* All other routes need AppProviders for backend integration */} string | null; + undo: () => void; + clear: () => void; +} + +interface Stroke { + color: string; + size: number; + points: Array<{ x: number; y: number }>; +} + +interface MobileDrawCanvasProps { + penColor: string; + penSize: number; + /** Fired when the canvas goes between empty and inked (gates Send/Undo). */ + onHasInkChange: (hasInk: boolean) => void; +} + +/** Padding kept around the ink when cropping the export, in CSS pixels. */ +const EXPORT_PADDING = 12; + +function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) { + const { points } = stroke; + if (points.length === 0) return; + + ctx.strokeStyle = stroke.color; + ctx.fillStyle = stroke.color; + ctx.lineWidth = stroke.size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + if (points.length === 1) { + // A tap: render a dot, which a zero-length stroke would not show. + ctx.beginPath(); + ctx.arc(points[0].x, points[0].y, stroke.size / 2, 0, Math.PI * 2); + ctx.fill(); + return; + } + + // Quadratic midpoint smoothing: each point becomes the control point of a + // curve to the midpoint of the next segment, turning jagged pointer samples + // into a pen-like line. + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length - 1; i++) { + const midX = (points[i].x + points[i + 1].x) / 2; + const midY = (points[i].y + points[i + 1].y) / 2; + ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY); + } + const last = points[points.length - 1]; + ctx.lineTo(last.x, last.y); + ctx.stroke(); +} + +export const MobileDrawCanvas = forwardRef< + MobileDrawCanvasHandle, + MobileDrawCanvasProps +>(function MobileDrawCanvas({ penColor, penSize, onHasInkChange }, ref) { + const canvasRef = useRef(null); + const strokesRef = useRef([]); + const activeStrokeRef = useRef(null); + // Live styling for the stroke currently being drawn, without re-rendering + const penRef = useRef({ color: penColor, size: penSize }); + penRef.current = { color: penColor, size: penSize }; + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + const dpr = window.devicePixelRatio || 1; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); + for (const stroke of strokesRef.current) drawStroke(ctx, stroke); + if (activeStrokeRef.current) drawStroke(ctx, activeStrokeRef.current); + }, []); + + // Match the backing store to the element's CSS size × devicePixelRatio, and + // re-match on resize/rotation. Strokes are CSS-space, so a redraw restores + // them at the new size. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.round(rect.width * dpr)); + canvas.height = Math.max(1, Math.round(rect.height * dpr)); + redraw(); + }; + + resize(); + const observer = new ResizeObserver(resize); + observer.observe(canvas); + return () => observer.disconnect(); + }, [redraw]); + + const pointFromEvent = (e: React.PointerEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }; + + const handlePointerDown = (e: React.PointerEvent) => { + // One stroke at a time: a second touch while drawing would scribble. + if (activeStrokeRef.current) return; + e.currentTarget.setPointerCapture(e.pointerId); + activeStrokeRef.current = { + color: penRef.current.color, + size: penRef.current.size, + points: [pointFromEvent(e)], + }; + redraw(); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + const stroke = activeStrokeRef.current; + if (!stroke) return; + // Coalesced events give the full sample train on high-rate digitizers, + // where the per-frame synthetic event alone would drop curvature. + const events = + "getCoalescedEvents" in e.nativeEvent + ? (e.nativeEvent as PointerEvent).getCoalescedEvents() + : [e.nativeEvent as PointerEvent]; + const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect(); + for (const ev of events) { + stroke.points.push({ + x: ev.clientX - rect.left, + y: ev.clientY - rect.top, + }); + } + redraw(); + }; + + const endStroke = () => { + const stroke = activeStrokeRef.current; + if (!stroke) return; + activeStrokeRef.current = null; + strokesRef.current = [...strokesRef.current, stroke]; + redraw(); + onHasInkChange(true); + }; + + useImperativeHandle(ref, () => ({ + exportPng: () => { + const strokes = strokesRef.current; + const canvas = canvasRef.current; + if (strokes.length === 0 || !canvas) return null; + + // Crop to the inked region so the signature stamps tightly, clamped to + // what was actually visible. + const rect = canvas.getBoundingClientRect(); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const stroke of strokes) { + const reach = stroke.size / 2 + EXPORT_PADDING; + for (const p of stroke.points) { + minX = Math.min(minX, p.x - reach); + minY = Math.min(minY, p.y - reach); + maxX = Math.max(maxX, p.x + reach); + maxY = Math.max(maxY, p.y + reach); + } + } + minX = Math.max(0, minX); + minY = Math.max(0, minY); + maxX = Math.min(rect.width, maxX); + maxY = Math.min(rect.height, maxY); + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + + const dpr = window.devicePixelRatio || 1; + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = Math.round(width * dpr); + exportCanvas.height = Math.round(height * dpr); + const ctx = exportCanvas.getContext("2d"); + if (!ctx) return null; + // Translate args are device pixels; scale args map stroke space to them. + ctx.setTransform(dpr, 0, 0, dpr, -minX * dpr, -minY * dpr); + for (const stroke of strokes) drawStroke(ctx, stroke); + return exportCanvas.toDataURL("image/png"); + }, + undo: () => { + strokesRef.current = strokesRef.current.slice(0, -1); + redraw(); + onHasInkChange(strokesRef.current.length > 0); + }, + clear: () => { + strokesRef.current = []; + activeStrokeRef.current = null; + redraw(); + onHasInkChange(false); + }, + })); + + return ( + + ); +}); diff --git a/frontend/editor/src/core/components/shared/MobileTransferModal.tsx b/frontend/editor/src/core/components/shared/MobileTransferModal.tsx new file mode 100644 index 0000000000..7a153c59fc --- /dev/null +++ b/frontend/editor/src/core/components/shared/MobileTransferModal.tsx @@ -0,0 +1,161 @@ +import { ReactNode } from "react"; +import { Modal, Stack, Text, Box, Alert } from "@mantine/core"; +import { QRCodeSVG } from "qrcode.react"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; +import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; +import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { useMobileTransferSession } from "@app/hooks/useMobileTransferSession"; + +/** + * The QR modal shell every phone-to-desktop transfer feature shares: session + * lifecycle, QR code, expiry/error alerts, and the fallback URL line. A + * feature supplies its copy, its public route, and what a received file + * means — the scanner converts to PDF, the sign tool routes it to a + * signature source. + */ +interface MobileTransferModalProps { + opened: boolean; + onClose: () => void; + /** SPA route the phone opens, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; + /** Called once per uploaded file; arrivals are untrusted, validate inside. */ + onFileReceived: (file: File) => void | Promise; + title: string; + description: string; + instructions: string; + expiryWarningTitle: string; + /** Interpolates the seconds remaining into the feature's warning copy. */ + formatExpiryWarning: (seconds: number) => string; + errorTitle: string; + sessionCreateErrorMessage: string; + pollingErrorMessage: string; + /** Rendered under the QR once files have arrived (e.g. a received-count badge). */ + renderReceived?: (count: number) => ReactNode; + qrSize?: number; +} + +export default function MobileTransferModal({ + opened, + onClose, + routePath, + onFileReceived, + title, + description, + instructions, + expiryWarningTitle, + formatExpiryWarning, + errorTitle, + sessionCreateErrorMessage, + pollingErrorMessage, + renderReceived, + qrSize = 240, +}: MobileTransferModalProps) { + const { config } = useAppConfig(); + + const { mobileUrl, filesReceived, error, timeRemaining, showExpiryWarning } = + useMobileTransferSession({ + active: opened, + routePath, + onFileReceived, + sessionCreateErrorMessage, + pollingErrorMessage, + // In dev the backend-advertised frontendUrl is the backend origin, which + // serves no SPA — the phone must open the Vite origin this page runs on, + // so let the URL builder fall back to it. An explicit server_url still + // wins, as an escape hatch. + configuredUrl: + localStorage.getItem("server_url") || + (import.meta.env.DEV ? "" : config?.frontendUrl || ""), + }); + + return ( + + + } + color="blue" + variant="light" + > + {description} + + + {showExpiryWarning && timeRemaining !== null && ( + } + title={expiryWarningTitle} + color="orange" + > + + {formatExpiryWarning(Math.ceil(timeRemaining / 1000))} + + + )} + + {error && ( + } + title={errorTitle} + color="red" + > + {error} + + )} + + + + + + + {filesReceived > 0 && renderReceived?.(filesReceived)} + + + {instructions} + + + + {mobileUrl} + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/MobileUploadModal.tsx b/frontend/editor/src/core/components/shared/MobileUploadModal.tsx index 76557d2b77..64ff82cb9f 100644 --- a/frontend/editor/src/core/components/shared/MobileUploadModal.tsx +++ b/frontend/editor/src/core/components/shared/MobileUploadModal.tsx @@ -1,17 +1,10 @@ -import { useEffect, useCallback, useState, useRef } from "react"; -import { Modal, Stack, Text, Badge, Box, Alert } from "@mantine/core"; -import { QRCodeSVG } from "qrcode.react"; +import { useCallback } from "react"; +import { Badge } from "@mantine/core"; 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 { BASE_PATH } from "@app/constants/app"; -import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl"; +import MobileTransferModal from "@app/components/shared/MobileTransferModal"; import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils"; -import apiClient from "@app/services/apiClient"; interface MobileUploadModalProps { opened: boolean; @@ -19,47 +12,6 @@ interface MobileUploadModalProps { 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 * @@ -73,371 +25,103 @@ export default function MobileUploadModal({ }: MobileUploadModalProps) { const { t } = useTranslation(); const { config } = useAppConfig(); + const convertToPdf = config?.mobileScannerConvertToPdf !== false; - const [sessionId, setSessionId] = useState(() => generateSessionId()); - const [sessionInfo, setSessionInfo] = useState(null); - const [filesReceived, setFilesReceived] = useState(0); - const [error, setError] = useState(null); - const [timeRemaining, setTimeRemaining] = useState(null); - const [showExpiryWarning, setShowExpiryWarning] = useState(false); - const pollIntervalRef = useRef(null); - const timerIntervalRef = useRef(null); - const processedFiles = useRef>(new Set()); + const handleFileReceived = useCallback( + async (received: File) => { + let file = received; - // Build the QR-code URL the phone opens. It must land on the public - // /mobile-scanner route under the app's base path, otherwise the phone hits - // the auth-gated catch-all route and is bounced to the login page. - const mobileUrl = buildMobileScannerUrl({ - configuredUrl: - localStorage.getItem("server_url") || config?.frontendUrl || "", - sessionId, - origin: window.location.origin, - basePath: BASE_PATH, - }); - - // Create session on backend - const createSession = useCallback( - async (newSessionId: string) => { - try { - const response = await apiClient.post( - `/api/v1/mobile-scanner/create-session/${newSessionId}`, - undefined, - { - responseType: "json", - }, - ); - - if (!response.status || response.status !== 200) { - throw new Error("Failed to create session"); - } - - const data = response.data; - setSessionInfo(data); - setError(null); - console.log("[MobileUploadModal] Session created:", data); - } catch (err) { - console.error("[MobileUploadModal] 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 apiClient.get( - `/api/v1/mobile-scanner/files/${sessionId}`, - ); - if (!response.status || response.status !== 200) { - throw new Error("Failed to check for files"); - } - - const data = response.data; - 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 apiClient.get( - `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, - { - responseType: "blob", - }, - ); - - if (downloadResponse.status === 200) { - const blob = downloadResponse.data; - let file = new File([blob], fileMetadata.filename, { - type: fileMetadata.contentType || "image/jpeg", - }); - - // Convert images to PDF if enabled - if ( - isImageFile(file) && - config?.mobileScannerConvertToPdf !== false - ) { - try { - file = await convertImageToPdf(file, { - imageResolution: config?.mobileScannerImageResolution as - | "full" - | "reduced" - | undefined, - pageFormat: config?.mobileScannerPageFormat as - | "keep" - | "A4" - | "letter" - | undefined, - stretchToFit: config?.mobileScannerStretchToFit, - }); - console.log( - "[MobileUploadModal] Converted image to PDF:", - file.name, - ); - } catch (convertError) { - console.warn( - "[MobileUploadModal] Failed to convert image to PDF, using original file:", - convertError, - ); - // Continue with original image file if conversion fails - } - } - - processedFiles.current.add(fileMetadata.filename); - setFilesReceived((prev) => prev + 1); - onFilesReceived([file]); - } - } catch (err) { - console.error( - "[MobileUploadModal] 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 + // Convert images to PDF if enabled + if (isImageFile(file) && convertToPdf) { try { - await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`); - console.log( - "[MobileUploadModal] Session cleaned up after file download", - ); - } catch (cleanupErr) { + file = await convertImageToPdf(file, { + imageResolution: config?.mobileScannerImageResolution as + | "full" + | "reduced" + | undefined, + pageFormat: config?.mobileScannerPageFormat as + | "keep" + | "A4" + | "letter" + | undefined, + stretchToFit: config?.mobileScannerStretchToFit, + }); + } catch (convertError) { console.warn( - "[MobileUploadModal] Failed to cleanup session after download:", - cleanupErr, + "[MobileUploadModal] Failed to convert image to PDF, using original file:", + convertError, ); + // Continue with original image file if conversion fails } } - } catch (err) { - console.error("[MobileUploadModal] 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(); - } - }, [opened, sessionId]); // Only run when opened changes - - useEffect(() => { - if (!opened) return; - - createSession(sessionId); - setFilesReceived(0); - setError(null); - setShowExpiryWarning(false); - processedFiles.current.clear(); - - return () => { - console.log("Cleaning up session on unmount/close:", sessionId); - apiClient - .delete(`/api/v1/mobile-scanner/session/${sessionId}`) - .catch((err) => - console.warn("[MobileUploadModal] Cleanup failed:", err), - ); - }; - }, [opened, sessionId, createSession]); - - // 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]); + onFilesReceived([file]); + }, + [config, convertToPdf, onFilesReceived], + ); return ( - - - } - color="blue" - variant="light" + description={ + convertToPdf + ? t( + "mobileUpload.description", + "Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.", + ) + : t( + "mobileUpload.descriptionNoConvert", + "Scan this QR code with your mobile device to upload photos.", + ) + } + instructions={ + convertToPdf + ? t( + "mobileUpload.instructions", + "Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.", + ) + : t( + "mobileUpload.instructionsNoConvert", + "Open the camera app on your phone and scan this code. Files will be uploaded through the server.", + ) + } + expiryWarningTitle={t( + "mobileUpload.expiryWarning", + "Session Expiring Soon", + )} + formatExpiryWarning={(seconds) => + t( + "mobileUpload.expiryWarningMessage", + "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", + { seconds }, + ) + } + errorTitle={t("mobileUpload.error", "Connection Error")} + sessionCreateErrorMessage={t( + "mobileUpload.sessionCreateError", + "Failed to create session", + )} + pollingErrorMessage={t( + "mobileUpload.pollingError", + "Error checking for files", + )} + renderReceived={(count) => ( + } > - - {config?.mobileScannerConvertToPdf !== false - ? t( - "mobileUpload.description", - "Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.", - ) - : t( - "mobileUpload.descriptionNoConvert", - "Scan this QR code with your mobile device to upload photos.", - )} - - - - {showExpiryWarning && timeRemaining !== null && ( - } - title={t("mobileUpload.expiryWarning", "Session Expiring Soon")} - color="orange" - > - - {t( - "mobileUpload.expiryWarningMessage", - "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", - { seconds: Math.ceil(timeRemaining / 1000) }, - )} - - - )} - - {error && ( - } - title={t("mobileUpload.error", "Connection Error")} - color="red" - > - {error} - - )} - - - - - - - {filesReceived > 0 && ( - } - > - {t("mobileUpload.filesReceived", "{{count}} file(s) received", { - count: filesReceived, - })} - - )} - - - {config?.mobileScannerConvertToPdf !== false - ? t( - "mobileUpload.instructions", - "Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.", - ) - : t( - "mobileUpload.instructionsNoConvert", - "Open the camera app on your phone and scan this code. Files will be uploaded through the server.", - )} - - - - {mobileUrl} - - - - + {t("mobileUpload.filesReceived", "{{count}} file(s) received", { + count, + })} + + )} + /> ); } diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx new file mode 100644 index 0000000000..c8607d55c4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx @@ -0,0 +1,176 @@ +/** + * Receive-flow contract for the phone-signature QR modal. + * + * The transfer session's upload endpoint accepts any file from anyone holding + * the QR URL, so the modal must treat arrivals as untrusted. Images become + * draw/photo payloads by filename prefix, a signature-text JSON payload is + * parsed and clamped field by field, and anything else is ignored. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import apiClient from "@app/services/apiClient"; +import { expectConsole } from "@app/tests/failOnConsole"; + +// Render the English fallbacks (the test i18n instance has no loaded locale). +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + }), +})); + +vi.mock("@app/services/apiClient", () => ({ + default: { + defaults: { baseURL: "http://localhost:8080" }, + post: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ config: { enableMobileSignature: true } }), +})); + +const mockedApi = vi.mocked(apiClient, true); + +const SESSION_INFO = { + sessionId: "s", + createdAt: Date.now(), + expiresAt: Date.now() + 600_000, + timeoutMs: 600_000, +}; + +function primeSession( + files: Array<{ filename: string; contentType: string; body?: string }>, +) { + mockedApi.post.mockResolvedValue({ + status: 200, + data: SESSION_INFO, + } as never); + mockedApi.delete.mockResolvedValue({ status: 200 } as never); + mockedApi.get.mockImplementation(((url: string, config?: unknown) => { + if (url.includes("/files/")) { + return Promise.resolve({ status: 200, data: { files } } as never); + } + if (url.includes("/download/")) { + const filename = url.split("/").pop() ?? ""; + const meta = files.find((f) => f.filename === filename); + return Promise.resolve({ + status: 200, + data: new Blob([meta?.body ?? "fake-bytes"], { + type: meta?.contentType, + }), + config, + } as never); + } + return Promise.reject(new Error(`unexpected GET ${url}`)); + }) as never); +} + +function renderModal( + onSignatureReceived: (payload: MobileSignaturePayload) => void, + onClose: () => void, +) { + return render( + + + , + ); +} + +describe("MobileSignatureModal", () => { + // clearAllMocks (not restoreAllMocks): the hook's unmount cleanup still + // calls apiClient.delete during test teardown, so implementations must + // survive until React Testing Library's auto-cleanup has unmounted. + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hands a drawn signature to the caller as a draw payload and closes", async () => { + primeSession([ + { filename: "signature-draw-1.png", contentType: "image/png" }, + ]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + const payload = onSignatureReceived.mock.calls[0][0]; + expect(payload.kind).toBe("draw"); + expect(payload.dataUrl).toMatch(/^data:image\/png/); + expect(onClose).toHaveBeenCalled(); + }); + + it("routes a photographed signature as a photo payload", async () => { + primeSession([ + { filename: "signature-photo-1.jpg", contentType: "image/jpeg" }, + ]); + const onSignatureReceived = vi.fn(); + + renderModal(onSignatureReceived, vi.fn()); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + expect(onSignatureReceived.mock.calls[0][0].kind).toBe("photo"); + }); + + it("parses a typed signature as text, clamping unknown font and colour", async () => { + primeSession([ + { + filename: "signature-text-1.json", + contentType: "application/json", + body: JSON.stringify({ + text: " Reece ", + fontFamily: "Wingdings", + color: "javascript:alert(1)", + }), + }, + ]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + expect(onSignatureReceived.mock.calls[0][0]).toEqual({ + kind: "text", + text: "Reece", + fontFamily: "Helvetica", + color: "#000000", + }); + expect(onClose).toHaveBeenCalled(); + }); + + it("ignores a non-image upload instead of setting it as the signature", async () => { + // Rejecting the upload logs a warning - that's the contract under test. + expectConsole.warn(/Ignoring non-image upload/); + primeSession([{ filename: "evil.html", contentType: "text/html" }]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + // The poll + download cycle must have run before we assert the negative. + await waitFor(() => + expect( + mockedApi.get.mock.calls.some(([url]) => + String(url).includes("/download/"), + ), + ).toBe(true), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onSignatureReceived).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx new file mode 100644 index 0000000000..82deae8b5f --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx @@ -0,0 +1,155 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import MobileTransferModal from "@app/components/shared/MobileTransferModal"; + +/** + * What the phone sent, routed to the matching signature source: ink and + * photos as pixels, typed signatures as data so they stay editable text. + */ +export type MobileSignaturePayload = + | { kind: "draw"; dataUrl: string } + | { kind: "photo"; dataUrl: string } + | { kind: "text"; text: string; fontFamily: string; color: string }; + +/** Fonts the sign tool's text mode offers; anything else falls back. */ +const TEXT_FONTS = new Set([ + "Helvetica", + "Times-Roman", + "Courier", + "Arial", + "Georgia", +]); +const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; +const MAX_TEXT_LENGTH = 200; + +interface MobileSignatureModalProps { + opened: boolean; + onClose: () => void; + onSignatureReceived: (payload: MobileSignaturePayload) => void; +} + +/** FileReader-based (rather than File.text/arrayBuffer, absent in jsdom). */ +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? "")); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +/** + * QR modal for creating a signature on a phone or tablet. The phone opens the + * public `/mobile-sign` page; the first valid arrival becomes the signature + * and the modal closes. + */ +export default function MobileSignatureModal({ + opened, + onClose, + onSignatureReceived, +}: MobileSignatureModalProps) { + const { t } = useTranslation(); + + // The session endpoints accept any upload from anyone holding the QR URL, + // so nothing here is trusted: images pass as pixels, a typed signature is + // parsed and clamped field by field, everything else is ignored. + const handleFileReceived = useCallback( + async (file: File) => { + if ( + file.type === "application/json" && + file.name.startsWith("signature-text") + ) { + try { + const parsed: unknown = JSON.parse(await readFileAsText(file)); + const record = parsed as Record; + const text = + typeof record?.text === "string" + ? record.text.trim().slice(0, MAX_TEXT_LENGTH) + : ""; + if (!text) return; + onSignatureReceived({ + kind: "text", + text, + fontFamily: TEXT_FONTS.has(record.fontFamily as string) + ? (record.fontFamily as string) + : "Helvetica", + color: HEX_COLOR.test(record.color as string) + ? (record.color as string) + : "#000000", + }); + onClose(); + } catch { + console.warn( + "[MobileSignatureModal] Ignoring malformed text payload", + ); + } + return; + } + + if (!file.type.startsWith("image/")) { + console.warn( + "[MobileSignatureModal] Ignoring non-image upload:", + file.type, + ); + return; + } + + await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result; + if (typeof dataUrl === "string") { + onSignatureReceived({ + kind: file.name.startsWith("signature-photo") ? "photo" : "draw", + dataUrl, + }); + onClose(); + } + resolve(); + }; + reader.onerror = () => resolve(); + reader.readAsDataURL(file); + }); + }, + [onSignatureReceived, onClose], + ); + + return ( + + t( + "sign.mobile.expiryWarningMessage", + "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", + { seconds }, + ) + } + errorTitle={t("sign.mobile.error", "Connection Error")} + sessionCreateErrorMessage={t( + "sign.mobile.sessionCreateError", + "Failed to create session", + )} + pollingErrorMessage={t( + "sign.mobile.pollingError", + "Error checking for the signature", + )} + /> + ); +} diff --git a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx index 648ada1c47..4aa9f8ffd3 100644 --- a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx @@ -34,6 +34,11 @@ import { AddSignatureResult, } from "@app/hooks/tools/sign/useSavedSignatures"; import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { buildSignaturePreview } from "@app/utils/signaturePreview"; type SignatureDrafts = { @@ -116,6 +121,12 @@ const SignSettings = ({ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false); + const [isMobileSignModalOpen, setIsMobileSignModalOpen] = useState(false); + const { config } = useAppConfig(); + const isMobileViewport = useIsMobile(); + // Drawing on a phone needs a second device, so the QR entry is desktop-only. + const canDrawOnPhone = + Boolean(config?.enableMobileSignature) && !isMobileViewport; // State for different signature types const [canvasSignatureData, setCanvasSignatureData] = useState< @@ -625,6 +636,71 @@ const SignSettings = ({ [onActivateSignaturePlacement], ); + // Route a signature made on a phone to the matching source, mirroring + // handleUseSavedSignature: ink is a canvas signature, a photo is an image + // signature, and typed text stays editable text rather than baked pixels. + const handleMobileSignatureReceived = useCallback( + (payload: MobileSignaturePayload) => { + // Receiving a signature is as clear an intent to place it as drawing + // one, so placement goes live even if it was paused beforehand. + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + if (payload.kind === "draw") { + if (parameters.signatureType !== "canvas") { + onParameterChange("signatureType", "canvas"); + } + handleCanvasSignatureChange(payload.dataUrl); + } else if (payload.kind === "photo") { + if (parameters.signatureType !== "image") { + onParameterChange("signatureType", "image"); + } + setImageSignatureData(payload.dataUrl); + } else { + if (parameters.signatureType !== "text") { + onParameterChange("signatureType", "text"); + } + onParameterChange("signerName", payload.text); + onParameterChange("fontFamily", payload.fontFamily); + onParameterChange("textColor", payload.color); + // Move the draft mirror in the same commit as the parameters. The + // record/restore effect pair otherwise sees them one commit apart and + // ping-pongs old draft against new params, wiping the received text + // and looping until React aborts the update depth. + const nextDraft = { + signerName: payload.text, + fontSize: parameters.fontSize ?? 16, + fontFamily: payload.fontFamily, + textColor: payload.color, + }; + lastSyncedTextDraft.current = nextDraft; + setSignatureDrafts((prev) => ({ ...prev, text: nextDraft })); + } + // Activate directly for every kind: the canvas-change handler only + // activates when the data actually changed, and the auto-activate + // effect only reacts to state transitions - neither fires for a + // repeat of the same signature. Fired twice because the first shot can + // land between the receive commit and the ready-state settling, where + // the placement effect immediately deactivates it; the second shot is + // after everything has settled, and re-activating is idempotent. + if (typeof window !== "undefined") { + window.setTimeout( + () => onActivateSignaturePlacement?.(), + PLACEMENT_ACTIVATION_DELAY, + ); + window.setTimeout(() => onActivateSignaturePlacement?.(), 500); + } else { + onActivateSignaturePlacement?.(); + } + }, + [ + parameters.signatureType, + parameters.fontSize, + onParameterChange, + handleCanvasSignatureChange, + onActivateSignaturePlacement, + ], + ); + const hasCanvasSignature = useMemo( () => Boolean(canvasSignatureData), [canvasSignatureData], @@ -980,6 +1056,29 @@ const SignSettings = ({ if (signatureSource === "image") { return ( + {imageSignatureData && ( + + {translate("image.previewAlt", + + )} + {canDrawOnPhone && ( + <> + + setIsMobileSignModalOpen(false)} + onSignatureReceived={handleMobileSignatureReceived} + /> + + )} {sourceOptions.length > 1 && ( 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 + throw new Error( + "Web Crypto API not available. Cannot generate secure session ID.", + ); +} + +export interface MobileTransferSessionInfo { + sessionId: string; + createdAt: number; + expiresAt: number; + timeoutMs: number; +} + +interface UseMobileTransferSessionParams { + /** Session exists and polling runs only while true (modal open). */ + active: boolean; + /** SPA route the phone opens, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; + /** Called once per newly uploaded file, in upload order. */ + onFileReceived: (file: File) => void | Promise; + /** Message shown when the backend refuses to create a session. */ + sessionCreateErrorMessage: string; + /** Message shown when polling for uploads fails. */ + pollingErrorMessage: string; + /** Host the phone should reach, when configured (server_url / frontendUrl). */ + configuredUrl?: string; +} + +export function useMobileTransferSession({ + active, + routePath, + onFileReceived, + sessionCreateErrorMessage, + pollingErrorMessage, + configuredUrl, +}: UseMobileTransferSessionParams) { + const [sessionId, setSessionId] = useState(() => generateSessionId()); + const [sessionInfo, setSessionInfo] = + useState(null); + const [filesReceived, setFilesReceived] = useState(0); + const [error, setError] = useState(null); + const [timeRemaining, setTimeRemaining] = useState(null); + const [showExpiryWarning, setShowExpiryWarning] = useState(false); + const pollIntervalRef = useRef(null); + const timerIntervalRef = useRef(null); + const processedFiles = useRef>(new Set()); + + // The QR-code URL the phone opens. It must land on the public route under + // the app's base path, otherwise the phone hits the auth-gated catch-all + // route and is bounced to the login page. + const mobileUrl = buildMobileRouteUrl({ + configuredUrl: configuredUrl ?? "", + sessionId, + origin: window.location.origin, + basePath: BASE_PATH, + routePath, + }); + + const createSession = useCallback( + async (newSessionId: string) => { + try { + const response = await apiClient.post( + `/api/v1/mobile-scanner/create-session/${newSessionId}`, + undefined, + { responseType: "json" }, + ); + + if (!response.status || response.status !== 200) { + throw new Error("Failed to create session"); + } + + setSessionInfo(response.data); + setError(null); + } catch (err) { + console.error("[useMobileTransferSession] create failed:", err); + setError(sessionCreateErrorMessage); + } + }, + [sessionCreateErrorMessage], + ); + + // 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 (!active) return; + + try { + const response = await apiClient.get( + `/api/v1/mobile-scanner/files/${sessionId}`, + ); + if (!response.status || response.status !== 200) { + throw new Error("Failed to check for files"); + } + + const files = response.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) return; + + for (const fileMetadata of newFiles) { + try { + const downloadResponse = await apiClient.get( + `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, + { responseType: "blob" }, + ); + + if (downloadResponse.status === 200) { + const file = new File( + [downloadResponse.data], + fileMetadata.filename, + { type: fileMetadata.contentType || "image/jpeg" }, + ); + processedFiles.current.add(fileMetadata.filename); + setFilesReceived((prev) => prev + 1); + await onFileReceived(file); + } + } catch (err) { + console.error( + "[useMobileTransferSession] download failed:", + fileMetadata.filename, + err, + ); + } + } + + // Delete the entire session immediately after downloading, so uploads + // sit on the server only for the seconds between polls. + try { + await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`); + } catch (cleanupErr) { + console.warn( + "[useMobileTransferSession] post-download cleanup failed:", + cleanupErr, + ); + } + } catch (err) { + console.error("[useMobileTransferSession] polling failed:", err); + setError(pollingErrorMessage); + } + }, [active, sessionId, onFileReceived, pollingErrorMessage]); + + // Create the session while active; delete it when deactivated/unmounted. + useEffect(() => { + if (!active) return; + + createSession(sessionId); + setFilesReceived(0); + setError(null); + setShowExpiryWarning(false); + processedFiles.current.clear(); + + return () => { + apiClient + .delete(`/api/v1/mobile-scanner/session/${sessionId}`) + .catch((err) => + console.warn("[useMobileTransferSession] cleanup failed:", err), + ); + }; + }, [active, sessionId, createSession]); + + // Poll for uploads while the session is live + useEffect(() => { + if (active && sessionInfo) { + pollIntervalRef.current = window.setInterval(pollForFiles, 2000); + pollForFiles(); + } else if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, [active, sessionInfo, pollForFiles]); + + // Session timeout timer: warn under a minute, regenerate on expiry + useEffect(() => { + if (!active || !sessionInfo) return; + + const updateTimer = () => { + const now = Date.now(); + const remaining = sessionInfo.expiresAt - now; + + if (remaining <= 0) { + setShowExpiryWarning(false); + regenerateSession(); + } else if (remaining <= 60000 && !showExpiryWarning) { + setShowExpiryWarning(true); + } + + setTimeRemaining(Math.max(0, remaining)); + }; + + updateTimer(); + timerIntervalRef.current = window.setInterval(updateTimer, 1000); + + return () => { + if (timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + } + }; + }, [active, sessionInfo, showExpiryWarning, regenerateSession]); + + return { + /** URL to encode in the QR code. */ + mobileUrl, + sessionInfo, + /** Count of files received this session (resets on regenerate). */ + filesReceived, + error, + /** Milliseconds until the session expires, once known. */ + timeRemaining, + /** True inside the final minute before expiry. */ + showExpiryWarning, + regenerateSession, + }; +} diff --git a/frontend/editor/src/core/pages/MobileSignPage.test.tsx b/frontend/editor/src/core/pages/MobileSignPage.test.tsx new file mode 100644 index 0000000000..84bcf1b736 --- /dev/null +++ b/frontend/editor/src/core/pages/MobileSignPage.test.tsx @@ -0,0 +1,100 @@ +/** + * Session-state contract for the phone-side signature page: a missing or + * expired session shows one clear error instead of a canvas whose Send would + * fail; a valid session shows the draw/type/photo tabs. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; +import MobileSignPage from "@app/pages/MobileSignPage"; + +vi.mock("@app/services/apiClient", () => ({ + default: { defaults: { baseURL: "http://localhost:8080" } }, +})); + +// Render the English fallbacks the assertions read (the test i18n instance +// has no loaded locale, so bare t() would render raw keys). +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + }), +})); + +// Branding components pull theme preferences from providers this page doesn't +// need for its session-state contract. +vi.mock("@app/components/shared/LogoIcon", () => ({ + LogoIcon: () => , +})); +vi.mock("@app/components/shared/Wordmark", () => ({ + Wordmark: () => , +})); + +function renderAt(path: string) { + return render( + + + + + , + ); +} + +describe("MobileSignPage", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + // jsdom has no ResizeObserver; the draw canvas sizes itself with one. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom's canvas has no real 2d context (and logs an error when asked); + // the draw canvas tolerates a null context. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the expired-session error when the URL has no session", async () => { + renderAt("/mobile-sign"); + + await waitFor(() => + expect(screen.getByText(/Session expired/i)).toBeInTheDocument(), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("shows the expired-session error when the backend rejects the session", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + json: async () => ({ valid: false }), + } as Response); + + renderAt("/mobile-sign?session=stale-session"); + + await waitFor(() => + expect(screen.getByText(/Session expired/i)).toBeInTheDocument(), + ); + }); + + it("shows the signature tabs once the session validates", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ valid: true }), + } as Response); + + renderAt("/mobile-sign?session=good-session"); + + await waitFor(() => expect(screen.getByText("Draw")).toBeInTheDocument()); + expect(screen.getByText("Type")).toBeInTheDocument(); + expect(screen.getByText("Photo")).toBeInTheDocument(); + expect(screen.getByText(/Send to computer/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/core/pages/MobileSignPage.tsx b/frontend/editor/src/core/pages/MobileSignPage.tsx new file mode 100644 index 0000000000..fd4034e0ba --- /dev/null +++ b/frontend/editor/src/core/pages/MobileSignPage.tsx @@ -0,0 +1,554 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + Alert, + Box, + Card, + Group, + Image, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { Button as DSButton } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; +import { useTranslation } from "react-i18next"; +import { LogoIcon } from "@app/components/shared/LogoIcon"; +import { Wordmark } from "@app/components/shared/Wordmark"; +import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded"; +import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; +import UndoRoundedIcon from "@mui/icons-material/UndoRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded"; +import PhotoCameraRoundedIcon from "@mui/icons-material/PhotoCameraRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; +import { + MobileDrawCanvas, + type MobileDrawCanvasHandle, +} from "@app/components/mobileSign/MobileDrawCanvas"; +import apiClient from "@app/services/apiClient"; + +// Use the configured API base (e.g. api.stirling.com), not the page origin. +const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, ""); + +type SignatureTab = "draw" | "type" | "photo"; + +// Ink pigments, not UI theme colours: they are baked into the exported PNG +// and transferred to the desktop, so they must be fixed literals. +const INK_COLORS = [ + { value: "#101010", label: "black" }, // theme-allow-color ink pigment, serialized into the signature + { value: "#1d4ed8", label: "blue" }, // theme-allow-color ink pigment, serialized into the signature +]; + +const PEN_SIZES = [ + { value: 2, label: "S" }, + { value: 3.5, label: "M" }, + { value: 6, label: "L" }, +]; + +/** + * The sign tool's own text-mode fonts, so a typed signature transfers as + * data and stays editable there. `css` approximates each for the on-phone + * preview; `value` is what the desktop's font parameter understands. + */ +const TYPE_FONTS = [ + { + value: "Helvetica", + css: "Helvetica, Arial, sans-serif", + label: "Helvetica", + }, + { + value: "Times-Roman", + css: "'Times New Roman', Times, serif", + label: "Times", + }, + { + value: "Courier", + css: "'Courier New', Courier, monospace", + label: "Courier", + }, + { value: "Arial", css: "Arial, sans-serif", label: "Arial" }, + { value: "Georgia", css: "Georgia, serif", label: "Georgia" }, +]; + +async function dataUrlToBlob(dataUrl: string): Promise { + const response = await fetch(dataUrl); + return response.blob(); +} + +/** + * MobileSignPage + * + * Phone-side page for sending a signature to the desktop: draw one (the main + * path), type one, or photograph one. Reached by scanning the QR code shown in + * the editor's Sign tool; the session comes from the QR URL and rides the same + * transfer backend as the mobile scanner. + */ +export default function MobileSignPage() { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const sessionId = searchParams.get("session"); + // Landscape phones (not tablets — hence the height cap) get a compact + // layout: branding hidden, tighter padding, shorter pad, so the canvas and + // the Send button fit on screen together. + const compactLandscape = + useMediaQuery("(orientation: landscape) and (max-height: 32rem)") ?? false; + + const [sessionValid, setSessionValid] = useState(null); + const [tab, setTab] = useState("draw"); + const [hasInk, setHasInk] = useState(false); + const [typedText, setTypedText] = useState(""); + const [typeFont, setTypeFont] = useState(TYPE_FONTS[0].value); + const [inkColor, setInkColor] = useState(INK_COLORS[0].value); + const [penSize, setPenSize] = useState(PEN_SIZES[1].value); + const [photoDataUrl, setPhotoDataUrl] = useState(null); + const [photoError, setPhotoError] = useState(null); + const [isSending, setIsSending] = useState(false); + const [sendError, setSendError] = useState(null); + const [sentCount, setSentCount] = useState(0); + const [justSent, setJustSent] = useState(false); + + const canvasHandle = useRef(null); + const photoInputRef = useRef(null); + const cameraInputRef = useRef(null); + + // Validate the session up front, so a stale QR shows one clear error rather + // than a canvas whose Send fails. + useEffect(() => { + if (!sessionId) { + setSessionValid(false); + return; + } + (async () => { + try { + const response = await fetch( + `${API_BASE}/api/v1/mobile-scanner/validate-session/${sessionId}`, + ); + const data = response.ok ? await response.json() : null; + setSessionValid(Boolean(data?.valid)); + } catch { + setSessionValid(false); + } + })(); + }, [sessionId]); + + const handlePhotoSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + setPhotoError( + t("mobileSign.photo.invalidType", "Please choose an image file."), + ); + return; + } + setPhotoError(null); + const reader = new FileReader(); + reader.onload = (event) => + setPhotoDataUrl((event.target?.result as string) ?? null); + reader.readAsDataURL(file); + }; + + /** + * What this tab sends: ink and photos as image files, typed signatures as a + * JSON payload (text + font + colour) so the desktop keeps them editable in + * the sign tool's text mode. The filename prefix tells the desktop which + * source the signature belongs to. + */ + const buildUpload = useCallback(async (): Promise<{ + blob: Blob; + filename: string; + } | null> => { + if (tab === "draw") { + const dataUrl = canvasHandle.current?.exportPng(); + if (!dataUrl) return null; + return { + blob: await dataUrlToBlob(dataUrl), + filename: `signature-draw-${Date.now()}.png`, + }; + } + if (tab === "type") { + const text = typedText.trim(); + if (!text) return null; + const payload = JSON.stringify({ + text, + fontFamily: typeFont, + color: inkColor, + }); + return { + blob: new Blob([payload], { type: "application/json" }), + filename: `signature-text-${Date.now()}.json`, + }; + } + if (!photoDataUrl) return null; + const blob = await dataUrlToBlob(photoDataUrl); + const extension = blob.type === "image/jpeg" ? "jpg" : "png"; + return { + blob, + filename: `signature-photo-${Date.now()}.${extension}`, + }; + }, [tab, typedText, typeFont, inkColor, photoDataUrl]); + + const canSend = + (tab === "draw" && hasInk) || + (tab === "type" && typedText.trim().length > 0) || + (tab === "photo" && photoDataUrl !== null); + + const handleSend = async () => { + if (!sessionId) return; + + setIsSending(true); + setSendError(null); + try { + const upload = await buildUpload(); + if (!upload) return; + const formData = new FormData(); + formData.append("files", upload.blob, upload.filename); + + const response = await fetch( + `${API_BASE}/api/v1/mobile-scanner/upload/${sessionId}`, + { method: "POST", body: formData }, + ); + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } + + setSentCount((count) => count + 1); + setJustSent(true); + // Reset the inputs so "send another" starts clean + canvasHandle.current?.clear(); + setTypedText(""); + setPhotoDataUrl(null); + if (photoInputRef.current) photoInputRef.current.value = ""; + if (cameraInputRef.current) cameraInputRef.current.value = ""; + } catch (err) { + console.error("[MobileSignPage] upload failed:", err); + setSendError( + t( + "mobileSign.sendError", + "Could not send the signature. Check the connection and try again.", + ), + ); + } finally { + setIsSending(false); + } + }; + + const header = ( + + + + + ); + + if (sessionValid === null) { + return ( + + {header} + + {t("mobileSign.validating", "Checking session…")} + + + ); + } + + if (!sessionValid) { + return ( + + {header} + } + color="red" + title={t("mobileSign.invalidSession", "Session expired")} + > + {t( + "mobileSign.invalidSessionMessage", + "This QR code is no longer valid. Open the Sign tool on your computer and scan the new code.", + )} + + + ); + } + + return ( + + {!compactLandscape && header} + + {justSent && ( + } + color="green" + mb="sm" + withCloseButton + onClose={() => setJustSent(false)} + > + {t( + "mobileSign.sentMessage", + "Signature sent to your computer. You can send another or close this page.", + )} + + )} + {sendError && ( + } + color="red" + mb="sm" + > + {sendError} + + )} + + + + fullWidth + value={tab} + onChange={setTab} + ariaLabel={t("mobileSign.tabsLabel", "Signature source")} + options={[ + // Same order as the sign tool's sources: canvas, image, text + { value: "draw", label: t("mobileSign.tab.draw", "Draw") }, + { value: "photo", label: t("mobileSign.tab.photo", "Photo") }, + { value: "type", label: t("mobileSign.tab.type", "Type") }, + ]} + /> + + + {tab === "draw" && ( + + + + + + + + {INK_COLORS.map((color) => ( + setInkColor(color.value)} + aria-label={color.label} + style={{ + width: 32, + height: 32, + borderRadius: "50%", + background: color.value, + cursor: "pointer", + border: + inkColor === color.value + ? "3px solid var(--mantine-color-blue-4)" + : "3px solid transparent", + }} + /> + ))} + setPenSize(Number(value))} + ariaLabel={t("mobileSign.penSizeLabel", "Pen size")} + options={PEN_SIZES.map((size) => ({ + value: String(size.value), + label: size.label, + }))} + /> + + + canvasHandle.current?.undo()} + leftSection={} + > + {t("mobileSign.undo", "Undo")} + + canvasHandle.current?.clear()} + leftSection={ + + } + > + {t("mobileSign.clear", "Clear")} + + + + + )} + + {tab === "type" && ( + + setTypedText(e.target.value)} + placeholder={t("mobileSign.type.placeholder", "Your name")} + autoComplete="name" + /> + + + + )} + + + } + > + {sentCount > 0 + ? t("mobileSign.sendAnother", "Send another signature") + : t("mobileSign.send", "Send to computer")} + + + + ); +} diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 72c7d7c5b6..2dafb3d07a 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -34,6 +34,7 @@ export interface AppConfig { serverCertificateEnabled?: boolean; hardwareSigningAvailable?: boolean; enableMobileScanner?: boolean; + enableMobileSignature?: boolean; mobileScannerConvertToPdf?: boolean; mobileScannerImageResolution?: string; mobileScannerPageFormat?: string; diff --git a/frontend/editor/src/core/utils/mobileScannerUrl.test.ts b/frontend/editor/src/core/utils/mobileScannerUrl.test.ts index 8890399b7b..024eeaf54a 100644 --- a/frontend/editor/src/core/utils/mobileScannerUrl.test.ts +++ b/frontend/editor/src/core/utils/mobileScannerUrl.test.ts @@ -9,10 +9,51 @@ */ import { describe, test, expect } from "vitest"; -import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl"; +import { + buildMobileRouteUrl, + buildMobileScannerUrl, +} from "@app/utils/mobileScannerUrl"; const sessionId = "abc-123"; +describe("buildMobileRouteUrl", () => { + test("routes other mobile pages (mobile-sign) with the base path", () => { + expect( + buildMobileRouteUrl({ + configuredUrl: "https://app.stirlingpdf.com", + sessionId, + origin: "https://app.stirlingpdf.com", + basePath: "/app", + routePath: "mobile-sign", + }), + ).toBe("https://app.stirlingpdf.com/app/mobile-sign?session=abc-123"); + }); + + test("configured URL with subpath keeps the route un-doubled", () => { + expect( + buildMobileRouteUrl({ + configuredUrl: "https://host.example/app/", + sessionId, + origin: "https://elsewhere.example", + basePath: "/app", + routePath: "mobile-sign", + }), + ).toBe("https://host.example/app/mobile-sign?session=abc-123"); + }); + + test("no configured URL falls back to origin + base path", () => { + expect( + buildMobileRouteUrl({ + configuredUrl: "", + sessionId, + origin: "http://192.168.1.20:8080", + basePath: "", + routePath: "mobile-sign", + }), + ).toBe("http://192.168.1.20:8080/mobile-sign?session=abc-123"); + }); +}); + describe("buildMobileScannerUrl", () => { test("origin-only frontendUrl keeps the app base path (SaaS web regression)", () => { expect( diff --git a/frontend/editor/src/core/utils/mobileScannerUrl.ts b/frontend/editor/src/core/utils/mobileScannerUrl.ts index c705069986..b44dcc358e 100644 --- a/frontend/editor/src/core/utils/mobileScannerUrl.ts +++ b/frontend/editor/src/core/utils/mobileScannerUrl.ts @@ -1,10 +1,10 @@ /** - * Build the URL a phone opens (via the QR code) to reach the SPA's - * `/mobile-scanner` route. + * Build the URL a phone opens (via a QR code) to reach one of the SPA's + * public mobile routes (`/mobile-scanner`, `/mobile-sign`). * - * That route is a public, top-level route. It lives under the app's base path, - * which is the router's `basename`. If the generated URL omits the base path, - * the phone loads a path the router can't match, falls through to the + * These routes are public, top-level routes. They live under the app's base + * path, which is the router's `basename`. If the generated URL omits the base + * path, the phone loads a path the router can't match, falls through to the * auth-gated catch-all route, and gets bounced to the login page. So the base * path must always be present. * @@ -18,15 +18,17 @@ * * With no usable configured URL, fall back to the current origin + base path. */ -export function buildMobileScannerUrl(params: { +export function buildMobileRouteUrl(params: { configuredUrl: string; sessionId: string; origin: string; basePath: string; + /** Route under the SPA base, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; }): string { - const { configuredUrl, sessionId, origin, basePath } = params; + const { configuredUrl, sessionId, origin, basePath, routePath } = params; const query = `?session=${sessionId}`; - const route = `${basePath}/mobile-scanner`; + const route = `${basePath}/${routePath}`; const trimmed = configuredUrl.trim(); if (trimmed) { @@ -35,7 +37,7 @@ export function buildMobileScannerUrl(params: { if (parsed.protocol === "http:" || parsed.protocol === "https:") { const subpath = parsed.pathname.replace(/\/+$/, ""); return subpath - ? `${parsed.origin}${subpath}/mobile-scanner${query}` + ? `${parsed.origin}${subpath}/${routePath}${query}` : `${parsed.origin}${route}${query}`; } } catch { @@ -45,3 +47,13 @@ export function buildMobileScannerUrl(params: { return `${origin}${route}${query}`; } + +/** The `/mobile-scanner` QR URL. See {@link buildMobileRouteUrl}. */ +export function buildMobileScannerUrl(params: { + configuredUrl: string; + sessionId: string; + origin: string; + basePath: string; +}): string { + return buildMobileRouteUrl({ ...params, routePath: "mobile-scanner" }); +} diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index ed36815bfd..de828c9f2d 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -15,6 +15,7 @@ import Onboarding from "@app/components/onboarding/Onboarding"; import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration"; const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; @@ -59,6 +60,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* Participant signing — public, token-gated, no auth required */} import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); // Import global styles import "@app/styles/tailwind.css"; @@ -83,6 +84,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* Admin-only route-set (the portal): its own top-level shell, mounted before the catch-all. */} {getAdminRouteExtensions()} diff --git a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx index b8691df30d..503087cbe4 100644 --- a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx @@ -34,6 +34,11 @@ import { AddSignatureResult, } from "@app/hooks/tools/sign/useSavedSignatures"; import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { buildSignaturePreview } from "@app/utils/signaturePreview"; type SignatureDrafts = { @@ -116,6 +121,12 @@ const SignSettings = ({ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false); + const [isMobileSignModalOpen, setIsMobileSignModalOpen] = useState(false); + const { config } = useAppConfig(); + const isMobileViewport = useIsMobile(); + // Drawing on a phone needs a second device, so the QR entry is desktop-only. + const canDrawOnPhone = + Boolean(config?.enableMobileSignature) && !isMobileViewport; // State for different signature types const [canvasSignatureData, setCanvasSignatureData] = useState< @@ -665,6 +676,71 @@ const SignSettings = ({ [onActivateSignaturePlacement], ); + // Route a signature made on a phone to the matching source, mirroring + // handleUseSavedSignature: ink is a canvas signature, a photo is an image + // signature, and typed text stays editable text rather than baked pixels. + const handleMobileSignatureReceived = useCallback( + (payload: MobileSignaturePayload) => { + // Receiving a signature is as clear an intent to place it as drawing + // one, so placement goes live even if it was paused beforehand. + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + if (payload.kind === "draw") { + if (parameters.signatureType !== "canvas") { + onParameterChange("signatureType", "canvas"); + } + handleCanvasSignatureChange(payload.dataUrl); + } else if (payload.kind === "photo") { + if (parameters.signatureType !== "image") { + onParameterChange("signatureType", "image"); + } + setImageSignatureData(payload.dataUrl); + } else { + if (parameters.signatureType !== "text") { + onParameterChange("signatureType", "text"); + } + onParameterChange("signerName", payload.text); + onParameterChange("fontFamily", payload.fontFamily); + onParameterChange("textColor", payload.color); + // Move the draft mirror in the same commit as the parameters. The + // record/restore effect pair otherwise sees them one commit apart and + // ping-pongs old draft against new params, wiping the received text + // and looping until React aborts the update depth. + const nextDraft = { + signerName: payload.text, + fontSize: parameters.fontSize ?? 16, + fontFamily: payload.fontFamily, + textColor: payload.color, + }; + lastSyncedTextDraft.current = nextDraft; + setSignatureDrafts((prev) => ({ ...prev, text: nextDraft })); + } + // Activate directly for every kind: the canvas-change handler only + // activates when the data actually changed, and the auto-activate + // effect only reacts to state transitions - neither fires for a + // repeat of the same signature. Fired twice because the first shot can + // land between the receive commit and the ready-state settling, where + // the placement effect immediately deactivates it; the second shot is + // after everything has settled, and re-activating is idempotent. + if (typeof window !== "undefined") { + window.setTimeout( + () => onActivateSignaturePlacement?.(), + PLACEMENT_ACTIVATION_DELAY, + ); + window.setTimeout(() => onActivateSignaturePlacement?.(), 500); + } else { + onActivateSignaturePlacement?.(); + } + }, + [ + parameters.signatureType, + parameters.fontSize, + onParameterChange, + handleCanvasSignatureChange, + onActivateSignaturePlacement, + ], + ); + const hasCanvasSignature = useMemo( () => Boolean(canvasSignatureData), [canvasSignatureData], @@ -1019,6 +1095,29 @@ const SignSettings = ({ if (signatureSource === "image") { return ( + {imageSignatureData && ( + + {translate("image.previewAlt", + + )} + {canDrawOnPhone && ( + <> + + setIsMobileSignModalOpen(false)} + onSignatureReceived={handleMobileSignatureReceived} + /> + + )} {sourceOptions.length > 1 && (