diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index c417ef1f6..66b97beb7 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -17,8 +17,9 @@ import "@app/styles/index.css"; // Import file ID debugging helpers (development only) import "@app/utils/fileIdSafety"; -// Minimal providers for mobile scanner - no API calls, no authentication -function MobileScannerProviders({ children }: { children: React.ReactNode }) { +// Minimal providers for the public, no-auth mobile-scanner page - no API +// calls, no authentication +function PublicRouteProviders({ children }: { children: React.ReactNode }) { return ( {children} @@ -34,9 +35,9 @@ export default function App() { + - + } /> diff --git a/frontend/editor/src/core/components/shared/MobileUploadModal.tsx b/frontend/editor/src/core/components/shared/MobileUploadModal.tsx index c76d4ce52..76557d2b7 100644 --- a/frontend/editor/src/core/components/shared/MobileUploadModal.tsx +++ b/frontend/editor/src/core/components/shared/MobileUploadModal.tsx @@ -8,7 +8,8 @@ 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"; +import { BASE_PATH } from "@app/constants/app"; +import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl"; import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils"; import apiClient from "@app/services/apiClient"; @@ -83,27 +84,16 @@ export default function MobileUploadModal({ const timerIntervalRef = useRef(null); const processedFiles = useRef>(new Set()); - // A configured server_url/frontendUrl already includes any subpath, so append - // the route directly; only the bare-origin fallback needs withBasePath. - const configuredUrl = ( - localStorage.getItem("server_url") || - config?.frontendUrl || - "" - ).trim(); - let mobileBase = ""; - if (configuredUrl) { - try { - const parsed = new URL(configuredUrl); - if (parsed.protocol === "http:" || parsed.protocol === "https:") { - mobileBase = configuredUrl.replace(/\/$/, ""); - } - } catch { - // invalid configured URL — fall back to origin - } - } - const mobileUrl = mobileBase - ? `${mobileBase}/mobile-scanner?session=${sessionId}` - : `${window.location.origin}${withBasePath("/mobile-scanner")}?session=${sessionId}`; + // 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( diff --git a/frontend/editor/src/core/utils/mobileScannerUrl.test.ts b/frontend/editor/src/core/utils/mobileScannerUrl.test.ts new file mode 100644 index 000000000..8890399b7 --- /dev/null +++ b/frontend/editor/src/core/utils/mobileScannerUrl.test.ts @@ -0,0 +1,115 @@ +/** + * Unit tests for buildMobileScannerUrl. + * + * Regression guard: the SaaS frontend is served under a base path (e.g. + * `/app`), and `/mobile-scanner` is a public route that only matches under that + * base path. The backend advertises `frontendUrl` as a bare origin (no + * subpath), so the generated QR URL must still carry the base path. Dropping it + * sent phones to the auth-gated catch-all route / login page. + */ + +import { describe, test, expect } from "vitest"; +import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl"; + +const sessionId = "abc-123"; + +describe("buildMobileScannerUrl", () => { + test("origin-only frontendUrl keeps the app base path (SaaS web regression)", () => { + expect( + buildMobileScannerUrl({ + configuredUrl: "https://app.stirlingpdf.com", + sessionId, + origin: "https://app.stirlingpdf.com", + basePath: "/app", + }), + ).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123"); + }); + + test("origin-only frontendUrl with a trailing slash keeps the app base path", () => { + expect( + buildMobileScannerUrl({ + configuredUrl: "https://app.stirlingpdf.com/", + sessionId, + origin: "https://app.stirlingpdf.com", + basePath: "/app", + }), + ).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123"); + }); + + test("configured URL that already carries the subpath is used verbatim (no doubled base)", () => { + expect( + buildMobileScannerUrl({ + configuredUrl: "https://app.stirlingpdf.com/app", + sessionId, + origin: "https://elsewhere.example", + basePath: "/app", + }), + ).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123"); + }); + + test("configured URL subpath with trailing slash is normalized", () => { + expect( + buildMobileScannerUrl({ + configuredUrl: "https://host.example/app/", + sessionId, + origin: "https://host.example", + basePath: "", + }), + ).toBe("https://host.example/app/mobile-scanner?session=abc-123"); + }); + + test("no base path and no configured URL uses the current origin", () => { + expect( + buildMobileScannerUrl({ + configuredUrl: "", + sessionId, + origin: "http://localhost:5173", + basePath: "", + }), + ).toBe("http://localhost:5173/mobile-scanner?session=abc-123"); + }); + + test("empty configured URL falls back to origin + base path", () => { + expect( + buildMobileScannerUrl({ + configuredUrl: " ", + sessionId, + origin: "https://app.stirlingpdf.com", + basePath: "/app", + }), + ).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123"); + }); + + test("custom port (LAN/desktop) is preserved", () => { + expect( + buildMobileScannerUrl({ + configuredUrl: "http://192.168.1.50:8080", + sessionId, + origin: "http://localhost:8080", + basePath: "", + }), + ).toBe("http://192.168.1.50:8080/mobile-scanner?session=abc-123"); + }); + + test("invalid configured URL falls back to the current origin + base path", () => { + expect( + buildMobileScannerUrl({ + configuredUrl: "not a url", + sessionId, + origin: "https://app.stirlingpdf.com", + basePath: "/app", + }), + ).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123"); + }); + + test("non-http(s) configured URL is ignored (no javascript: injection)", () => { + expect( + buildMobileScannerUrl({ + configuredUrl: "javascript:alert(1)", + sessionId, + origin: "https://app.stirlingpdf.com", + basePath: "/app", + }), + ).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123"); + }); +}); diff --git a/frontend/editor/src/core/utils/mobileScannerUrl.ts b/frontend/editor/src/core/utils/mobileScannerUrl.ts new file mode 100644 index 000000000..c70506998 --- /dev/null +++ b/frontend/editor/src/core/utils/mobileScannerUrl.ts @@ -0,0 +1,47 @@ +/** + * Build the URL a phone opens (via the QR code) to reach the SPA's + * `/mobile-scanner` route. + * + * 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 + * auth-gated catch-all route, and gets bounced to the login page. So the base + * path must always be present. + * + * A configured `server_url`/`frontendUrl` supplies the host the phone should + * reach (desktop / LAN / reverse proxy): + * - origin only (no subpath): apply the app's base path. The backend's + * `resolveFrontendUrl` advertises a bare origin with no subpath, so this is + * the common SaaS web case (frontend served under e.g. `/app`). + * - already carries a subpath: it points at the target SPA's base directly, + * so use it verbatim and do not add the base path again (no doubled base). + * + * With no usable configured URL, fall back to the current origin + base path. + */ +export function buildMobileScannerUrl(params: { + configuredUrl: string; + sessionId: string; + origin: string; + basePath: string; +}): string { + const { configuredUrl, sessionId, origin, basePath } = params; + const query = `?session=${sessionId}`; + const route = `${basePath}/mobile-scanner`; + + const trimmed = configuredUrl.trim(); + if (trimmed) { + try { + const parsed = new URL(trimmed); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + const subpath = parsed.pathname.replace(/\/+$/, ""); + return subpath + ? `${parsed.origin}${subpath}/mobile-scanner${query}` + : `${parsed.origin}${route}${query}`; + } + } catch { + // invalid configured URL — fall through to the current-origin default + } + } + + return `${origin}${route}${query}`; +} diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index c894e6491..4bdfb2ca4 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -26,8 +26,9 @@ import "@app/styles/auth-theme.css"; // Import file ID debugging helpers (development only) import "@app/utils/fileIdSafety"; -// Minimal providers for mobile scanner - no API calls, no authentication -function MobileScannerProviders({ children }: { children: React.ReactNode }) { +// Minimal providers for public, no-auth pages (mobile scanner, participant +// signing) - no API calls, no authentication +function PublicRouteProviders({ children }: { children: React.ReactNode }) { return ( {children} @@ -50,9 +51,9 @@ export default function App() { + - + } /> @@ -60,9 +61,9 @@ export default function App() { + - + } /> diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 418903544..c706c964d 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -1,7 +1,9 @@ -import { Suspense } from "react"; +import { Suspense, type ReactNode } from "react"; import { Routes, Route, useLocation } from "react-router-dom"; import { isAuthRoute } from "@app/utils/pathUtils"; import { AppProviders } from "@app/components/AppProviders"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider"; import { setBaseUrl } from "@app/constants/app"; import type { AppConfig } from "@app/contexts/AppConfigContext"; import { AppLayout } from "@app/components/AppLayout"; @@ -13,6 +15,8 @@ import Signup from "@app/routes/Signup"; import AuthCallback from "@app/routes/AuthCallback"; import ResetPassword from "@app/routes/ResetPassword"; import OAuthConsent from "@app/routes/OAuthConsent"; +import ShareLinkPage from "@app/routes/ShareLinkPage"; +import MobileScannerPage from "@app/pages/MobileScannerPage"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; @@ -31,6 +35,17 @@ function handleConfigLoaded(config: AppConfig) { if (config.baseUrl) setBaseUrl(config.baseUrl); } +// Minimal providers for the public, no-auth mobile-scanner page. Just theme + +// preferences, no AppProviders, so no auth and no backend bootstrap - it +// renders without a logged-in session. +function PublicRouteProviders({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + /** * Onboarding and trial-expired modals must never cover auth-flow pages * (login, signup, OAuth consent): they steal focus from the task the user @@ -54,22 +69,47 @@ function NonAuthBootstraps() { export default function App() { return ( }> - - - - - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + {/* Mobile scanner - public, no auth. Opened on a phone via the QR code, + so it must render without a logged-in session. Kept outside + AppProviders or it falls through to the auth-gated catch-all. */} + + + + } + /> + + {/* Everything else needs the auth/backend providers. */} + + + + + } /> + } /> + } /> + } /> + } /> + {/* Shared-file links. Team invites are NOT routed here: on + SaaS they are accepted in-app via the Supabase team + invitation banner, not the Spring password-based + /invite/:token page used by the self-hosted build. */} + } /> + } /> + + + + + } + /> + ); }