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 file ID debugging helpers (development only)
import "@app/utils/fileIdSafety"; import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication // Minimal providers for the public, no-auth mobile-scanner page - no API
function MobileScannerProviders({ children }: { children: React.ReactNode }) { // calls, no authentication
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
return ( return (
<PreferencesProvider> <PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider> <RainbowThemeProvider>{children}</RainbowThemeProvider>
@@ -34,9 +35,9 @@ export default function App() {
<Route <Route
path="/mobile-scanner" path="/mobile-scanner"
element={ element={
<MobileScannerProviders> <PublicRouteProviders>
<MobileScannerPage /> <MobileScannerPage />
</MobileScannerProviders> </PublicRouteProviders>
} }
/> />
@@ -8,7 +8,8 @@ import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; 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 { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils";
import apiClient from "@app/services/apiClient"; import apiClient from "@app/services/apiClient";
@@ -83,27 +84,16 @@ export default function MobileUploadModal({
const timerIntervalRef = useRef<number | null>(null); const timerIntervalRef = useRef<number | null>(null);
const processedFiles = useRef<Set<string>>(new Set()); const processedFiles = useRef<Set<string>>(new Set());
// A configured server_url/frontendUrl already includes any subpath, so append // Build the QR-code URL the phone opens. It must land on the public
// the route directly; only the bare-origin fallback needs withBasePath. // /mobile-scanner route under the app's base path, otherwise the phone hits
const configuredUrl = ( // the auth-gated catch-all route and is bounced to the login page.
localStorage.getItem("server_url") || const mobileUrl = buildMobileScannerUrl({
config?.frontendUrl || configuredUrl:
"" localStorage.getItem("server_url") || config?.frontendUrl || "",
).trim(); sessionId,
let mobileBase = ""; origin: window.location.origin,
if (configuredUrl) { basePath: BASE_PATH,
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}`;
// Create session on backend // Create session on backend
const createSession = useCallback( 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 file ID debugging helpers (development only)
import "@app/utils/fileIdSafety"; import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication // Minimal providers for public, no-auth pages (mobile scanner, participant
function MobileScannerProviders({ children }: { children: React.ReactNode }) { // signing) - no API calls, no authentication
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
return ( return (
<PreferencesProvider> <PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider> <RainbowThemeProvider>{children}</RainbowThemeProvider>
@@ -50,9 +51,9 @@ export default function App() {
<Route <Route
path="/mobile-scanner" path="/mobile-scanner"
element={ element={
<MobileScannerProviders> <PublicRouteProviders>
<MobileScannerPage /> <MobileScannerPage />
</MobileScannerProviders> </PublicRouteProviders>
} }
/> />
@@ -60,9 +61,9 @@ export default function App() {
<Route <Route
path="/workflow/sign/:token" path="/workflow/sign/:token"
element={ element={
<MobileScannerProviders> <PublicRouteProviders>
<ParticipantViewPage /> <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 { Routes, Route, useLocation } from "react-router-dom";
import { isAuthRoute } from "@app/utils/pathUtils"; import { isAuthRoute } from "@app/utils/pathUtils";
import { AppProviders } from "@app/components/AppProviders"; 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 { setBaseUrl } from "@app/constants/app";
import type { AppConfig } from "@app/contexts/AppConfigContext"; import type { AppConfig } from "@app/contexts/AppConfigContext";
import { AppLayout } from "@app/components/AppLayout"; import { AppLayout } from "@app/components/AppLayout";
@@ -13,6 +15,8 @@ import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback"; import AuthCallback from "@app/routes/AuthCallback";
import ResetPassword from "@app/routes/ResetPassword"; import ResetPassword from "@app/routes/ResetPassword";
import OAuthConsent from "@app/routes/OAuthConsent"; 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 OnboardingBootstrap from "@app/components/OnboardingBootstrap";
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap"; import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
@@ -31,6 +35,17 @@ function handleConfigLoaded(config: AppConfig) {
if (config.baseUrl) setBaseUrl(config.baseUrl); 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 * Onboarding and trial-expired modals must never cover auth-flow pages
* (login, signup, OAuth consent): they steal focus from the task the user * (login, signup, OAuth consent): they steal focus from the task the user
@@ -54,22 +69,47 @@ function NonAuthBootstraps() {
export default function App() { export default function App() {
return ( return (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<AppProviders <Routes>
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }} {/* Mobile scanner - public, no auth. Opened on a phone via the QR code,
> so it must render without a logged-in session. Kept outside
<AppLayout> AppProviders or it falls through to the auth-gated catch-all. */}
<NonAuthBootstraps /> <Route
<Routes> path="/mobile-scanner"
<Route path="/login" element={<Login />} /> element={
<Route path="/signup" element={<Signup />} /> <PublicRouteProviders>
<Route path="/auth/callback" element={<AuthCallback />} /> <MobileScannerPage />
<Route path="/auth/reset" element={<ResetPassword />} /> </PublicRouteProviders>
<Route path="/oauth/consent" element={<OAuthConsent />} /> }
<Route path="/*" element={<Landing />} /> />
</Routes>
<OnboardingTour /> {/* Everything else needs the auth/backend providers. */}
</AppLayout> <Route
</AppProviders> 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> </Suspense>
); );
} }