Fix SaaS mobile scanner being auth-gated under /app base path (#6642)

This commit is contained in:
Anthony Stirling
2026-06-12 13:13:49 +01:00
committed by GitHub
parent b11c272e87
commit f1ed850a73
6 changed files with 243 additions and 49 deletions
+5 -4
View File
@@ -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 (
<PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
@@ -34,9 +35,9 @@ export default function App() {
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<PublicRouteProviders>
<MobileScannerPage />
</MobileScannerProviders>
</PublicRouteProviders>
}
/>
@@ -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<number | null>(null);
const processedFiles = useRef<Set<string>>(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(
@@ -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");
});
});
@@ -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}`;
}
+7 -6
View File
@@ -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 (
<PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
@@ -50,9 +51,9 @@ export default function App() {
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<PublicRouteProviders>
<MobileScannerPage />
</MobileScannerProviders>
</PublicRouteProviders>
}
/>
@@ -60,9 +61,9 @@ export default function App() {
<Route
path="/workflow/sign/:token"
element={
<MobileScannerProviders>
<PublicRouteProviders>
<ParticipantViewPage />
</MobileScannerProviders>
</PublicRouteProviders>
}
/>
+57 -17
View File
@@ -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 (
<PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
</PreferencesProvider>
);
}
/**
* 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 (
<Suspense fallback={<LoadingFallback />}>
<AppProviders
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
>
<AppLayout>
<NonAuthBootstraps />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/reset" element={<ResetPassword />} />
<Route path="/oauth/consent" element={<OAuthConsent />} />
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
</AppProviders>
<Routes>
{/* 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. */}
<Route
path="/mobile-scanner"
element={
<PublicRouteProviders>
<MobileScannerPage />
</PublicRouteProviders>
}
/>
{/* Everything else needs the auth/backend providers. */}
<Route
path="*"
element={
<AppProviders
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
>
<AppLayout>
<NonAuthBootstraps />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/reset" element={<ResetPassword />} />
<Route path="/oauth/consent" element={<OAuthConsent />} />
{/* 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. */}
<Route path="/share/:token" element={<ShareLinkPage />} />
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}