mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Fix SaaS mobile scanner being auth-gated under /app base path (#6642)
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user