From dc75d399bcc5585bd33eae8b63955f3f885e3923 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:09:18 +0000 Subject: [PATCH] Editor url role routing (#7409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - `/` is now a router, not a page: signed-in users go to the processor or the editor by role. The editor lives at `/editor`. - `/editor` never routes — always the editor, so processor users have a URL that won't bounce them. - Core and desktop keep the editor at `/` (no processor, nothing to route between). - `/editor` signed out → `/login` → back to `/editor` after signing in. - Signed-out visitors aren't redirected: `/` renders the app and Landing owns it (login page / SaaS inline sign-in / backend-down screen). - `RootGate` wraps the app instead of being its own route, so nothing boots on the way to the processor and nothing remounts on the way to the editor. - Login resolves its own destination instead of bouncing through `/`. - Replaces the old once-per-login `LoginLandingRedirect` + sessionStorage flag. Landing flag and Settings preference unchanged. - Separate commit: theme-lint crashed on files deleted in the working tree (`git ls-files` is the index view). Any branch deleting a source file hit it. - Sign-out untouched. Tool routes stay top-level, so no deep links or SEO break. Future PR to allow users to configure their own routing from / for their profile --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- frontend/editor/public/og-metadata.json | 6 + frontend/editor/public/og-metadata.saas.json | 16 +- .../editor/scripts/generate-og-metadata.mjs | 2 + frontend/editor/scripts/lint/theme-lint.mjs | 49 ++--- .../components/filesPage/FileManagerView.tsx | 5 +- .../core/components/shared/AppConfigModal.tsx | 3 +- .../AdminTourOrchestrationContext.tsx | 9 +- frontend/editor/src/core/hooks/useUrlSync.ts | 5 +- frontend/editor/src/core/pages/HomePage.tsx | 5 +- .../src/core/pages/MobileScannerPage.tsx | 3 +- .../editor/src/core/routes/editorBasename.ts | 10 + .../enterprise/license-and-features.spec.ts | 2 +- .../editor/src/core/tests/helpers/login.ts | 4 +- .../src/core/tests/helpers/stub-test-base.ts | 5 +- .../tests/live/authentication-login.spec.ts | 8 +- .../core/tests/live/watched-folders.spec.ts | 4 +- .../core/tests/stubbed/audit-log-ui.spec.ts | 2 +- .../classification-heuristic-upload.spec.ts | 5 +- .../tests/stubbed/cookie-preferences.spec.ts | 2 +- .../src/core/tests/stubbed/files-page.spec.ts | 10 +- .../tests/stubbed/first-login-modal.spec.ts | 6 +- .../core/tests/stubbed/license-states.spec.ts | 2 +- .../stubbed/login-agreement-modal.spec.ts | 12 +- .../core/tests/stubbed/main-dashboard.spec.ts | 4 +- .../src/core/tests/stubbed/navigation.spec.ts | 6 +- .../stubbed/page-editor-rotation.spec.ts | 5 +- .../stubbed/premium-feature-gates.spec.ts | 4 +- .../src/core/tests/stubbed/seed.spec.ts | 2 +- .../src/core/tests/stubbed/settings.spec.ts | 4 +- .../src/core/tests/stubbed/teams-ui.spec.ts | 2 +- .../tests/stubbed/tool-pages-common.spec.ts | 2 +- frontend/editor/src/core/utils/urlRouting.ts | 5 +- .../editor/src/desktop/routes/RootGate.tsx | 12 ++ .../src/desktop/routes/editorBasename.ts | 6 + frontend/editor/src/portal/auth/editorUrl.ts | 17 +- .../editor/src/portal/components/Sidebar.tsx | 3 +- frontend/editor/src/proprietary/App.tsx | 49 ++--- .../components/LoginLandingRedirect.test.tsx | 180 ------------------ .../components/LoginLandingRedirect.tsx | 120 ------------ .../shared/config/LoginLandingSetting.tsx | 8 +- .../hooks/useWatchedFolderUrlSync.ts | 3 +- .../proprietary/routes/AuthCallback.test.tsx | 8 +- .../src/proprietary/routes/AuthCallback.tsx | 10 +- .../editor/src/proprietary/routes/Landing.tsx | 18 +- .../src/proprietary/routes/Login.test.tsx | 80 +++++++- .../editor/src/proprietary/routes/Login.tsx | 66 ++++--- .../src/proprietary/routes/RootGate.test.tsx | 157 +++++++++++++++ .../src/proprietary/routes/RootGate.tsx | 71 +++++++ .../proprietary/routes/ShareLinkLoader.tsx | 9 +- .../src/proprietary/routes/ShareLinkPage.tsx | 3 +- .../src/proprietary/routes/editorBasename.ts | 9 + .../proprietary/utils/loginLanding.test.ts | 42 ++-- .../src/proprietary/utils/loginLanding.ts | 118 +++++++----- frontend/editor/src/prototypes/App.tsx | 44 +++-- frontend/editor/src/saas/App.tsx | 51 ++--- .../editor/src/saas/routes/AuthCallback.tsx | 12 +- frontend/editor/src/saas/routes/Landing.tsx | 27 ++- frontend/editor/src/saas/routes/Login.tsx | 10 +- frontend/editor/src/saas/utils/pathUtils.ts | 4 +- 59 files changed, 731 insertions(+), 615 deletions(-) create mode 100644 frontend/editor/src/core/routes/editorBasename.ts create mode 100644 frontend/editor/src/desktop/routes/RootGate.tsx create mode 100644 frontend/editor/src/desktop/routes/editorBasename.ts delete mode 100644 frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx delete mode 100644 frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx create mode 100644 frontend/editor/src/proprietary/routes/RootGate.test.tsx create mode 100644 frontend/editor/src/proprietary/routes/RootGate.tsx create mode 100644 frontend/editor/src/proprietary/routes/editorBasename.ts diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index e975af703b..2f7134b908 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -305,6 +305,11 @@ "title": "Air-gapped Setup - Stirling PDF", "description": "Link to air-gapped setup guide" }, + "/editor": { + "image": "/og_images/home.png", + "title": "Editor - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, "/login": { "image": "/og_images/home.png", "title": "Sign In - Stirling PDF", @@ -645,6 +650,7 @@ "/overlay-pdf": "overlayPdfs", "/split-pdf-by-sections": "split", "/split-pdf-by-chapters": "split", + "/editor": "/editor", "/login": "/login", "/mobile-scanner": "/mobile-scanner", "/files": "/files", diff --git a/frontend/editor/public/og-metadata.saas.json b/frontend/editor/public/og-metadata.saas.json index 3e7151469c..ee8ca77588 100644 --- a/frontend/editor/public/og-metadata.saas.json +++ b/frontend/editor/public/og-metadata.saas.json @@ -306,6 +306,12 @@ "title": "Air-gapped Setup - Stirling PDF", "description": "Link to air-gapped setup guide" }, + "/editor": { + "image": "/og_images/saas/app-editor.png", + "title": "Stirling - The world's most secure PDF editor", + "ogTitle": "The world's most secure PDF editor", + "description": "Edit, sign, redact, and convert PDFs in your browser. Free forever, open source, and self-hostable." + }, "/login": { "image": "/og_images/home.png", "title": "Sign In - Stirling PDF", @@ -541,12 +547,6 @@ "title": "Stirling Processor - Govern every PDF your organization touches", "ogTitle": "Govern every PDF your organization touches", "description": "Redaction, retention, and encryption policies enforced everywhere PDFs enter your org. Distribute the free Editor anywhere. 1¢ per PDF." - }, - "/editor": { - "image": "/og_images/saas/app-editor.png", - "title": "Stirling - The world's most secure PDF editor", - "ogTitle": "The world's most secure PDF editor", - "description": "Edit, sign, redact, and convert PDFs in your browser. Free forever, open source, and self-hostable." } }, "byPath": { @@ -663,6 +663,7 @@ "/overlay-pdf": "overlayPdfs", "/split-pdf-by-sections": "split", "/split-pdf-by-chapters": "split", + "/editor": "/editor", "/login": "/login", "/mobile-scanner": "/mobile-scanner", "/files": "/files", @@ -709,7 +710,6 @@ "/settings/payg": "/settings/payg", "/settings/account-link": "/settings/account-link", "/signup": "/signup", - "/processor": "/processor", - "/editor": "/editor" + "/processor": "/processor" } } diff --git a/frontend/editor/scripts/generate-og-metadata.mjs b/frontend/editor/scripts/generate-og-metadata.mjs index fe928850f9..942bd9767e 100644 --- a/frontend/editor/scripts/generate-og-metadata.mjs +++ b/frontend/editor/scripts/generate-og-metadata.mjs @@ -180,6 +180,8 @@ const humanizeLabel = (s) => .replace(/\b\w/g, (c) => c.toUpperCase()); const pageTitles = { + // The editor's own URL ("/" only routes, by role). + "/editor": "Editor", "/login": "Sign In", "/mobile-scanner": "Mobile Scanner", "/files": "Files", diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 22a3f4dfe8..494fc03cca 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -17,7 +17,7 @@ // // Structural black / white / transparent (shadows, scrims) are always allowed. -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { execSync } from "node:child_process"; import { relative, resolve, join } from "node:path"; @@ -566,12 +566,23 @@ function reportToneContrast() { // App-wide guard that source CSS routes every colour through the palette. File // list from `git ls-files` (a VCS query, never a directory walk feeding a read). // primitives.css (the literal home) and generated output.css are exempt. -function checkAppCss() { - const EXEMPT = /(?:^|\/)(?:primitives\.css|output\.css)$/; - const listed = execSync("git ls-files -- editor/src", { encoding: "utf8" }) +/** + * Tracked source files that are actually present. `git ls-files` is the index + * view, so it still lists files deleted in the working tree - reading one of + * those throws ENOENT and takes the whole lint down with it. + */ +function trackedFiles() { + return execSync("git ls-files -- editor/src", { encoding: "utf8" }) .split("\n") .map((l) => l.trim()) - .filter((l) => l && l.endsWith(".css") && !EXEMPT.test(l)); + .filter((l) => l && existsSync(l)); +} + +function checkAppCss() { + const EXEMPT = /(?:^|\/)(?:primitives\.css|output\.css)$/; + const listed = trackedFiles().filter( + (l) => l.endsWith(".css") && !EXEMPT.test(l), + ); const violations = []; const lineOf = (text, index) => text.slice(0, index).split("\n").length; @@ -675,13 +686,9 @@ function codeRgbIsColour(inner) { return k !== "0,0,0" && k !== "255,255,255"; } function checkCodeColors() { - const files = execSync("git ls-files -- editor/src", { encoding: "utf8" }) - .split("\n") - .map((l) => l.trim()) - .filter( - (l) => - /\.(ts|tsx)$/.test(l) && !CODE_EXEMPT_PATH.some((re) => re.test(l)), - ); + const files = trackedFiles().filter( + (l) => /\.(ts|tsx)$/.test(l) && !CODE_EXEMPT_PATH.some((re) => re.test(l)), + ); const violations = []; for (const rel of files) { const raw = readFileSync(rel, "utf8"); @@ -715,10 +722,7 @@ function checkCodeColors() { // reference has a definition somewhere (any source .css/.ts/.tsx) or a fallback. // Runtime-injected families (--user-*, --mantine-*, --accent-*) are out of scope. function checkTokenResolution() { - const files = execSync("git ls-files -- editor/src", { encoding: "utf8" }) - .split("\n") - .map((l) => l.trim()) - .filter((l) => /\.(css|ts|tsx)$/.test(l)); + const files = trackedFiles().filter((l) => /\.(css|ts|tsx)$/.test(l)); const DEF_RE = /(--[a-z0-9-]+)\s*:/gi; // Capture the token and the char that follows it (`,` ⇒ has a fallback). const REF_RE = /var\(\s*(--[a-z0-9-]+)\s*(,|\))/gi; @@ -750,14 +754,11 @@ const PRIMITIVE_LAYER = [ /^editor\/src\/core\/ui\/accents\.css$/, ]; function checkNoPrimitives() { - const files = execSync("git ls-files -- editor/src", { encoding: "utf8" }) - .split("\n") - .map((l) => l.trim()) - .filter( - (l) => - /\.(css|scss|ts|tsx)$/.test(l) && - !PRIMITIVE_LAYER.some((re) => re.test(l)), - ); + const files = trackedFiles().filter( + (l) => + /\.(css|scss|ts|tsx)$/.test(l) && + !PRIMITIVE_LAYER.some((re) => re.test(l)), + ); const REF = /var\(\s*(--p-[a-z0-9-]+)/g; const violations = []; const lineOf = (text, index) => text.slice(0, index).split("\n").length; diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index cc6a74d09e..3ed5096efb 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -67,6 +67,7 @@ import { parseFilesPageDragPayload, } from "@app/components/filesPage/dragDrop"; import { clearFilesPageReturnRoute } from "@app/components/filesPage/filesPageReturnRoute"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import "@app/components/filesPage/FilesPage.css"; export default function FileManagerView() { @@ -578,7 +579,7 @@ export default function FileManagerView() { } else if (materialized.length > 1) { navActions.setWorkbench("fileEditor"); } - navigate("/"); + navigate(EDITOR_BASENAME); }; requestNavigation(() => { @@ -681,7 +682,7 @@ export default function FileManagerView() { const handleClose = useCallback(() => { // Drop the return-route hint so the workbench doesn't show a stale back. clearFilesPageReturnRoute(); - navigate("/"); + navigate(EDITOR_BASENAME); }, [navigate]); // ─── keyboard shortcuts ───────────────────────────────────────────────── diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 3805b4b912..b5b3224985 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -31,6 +31,7 @@ import { } from "@app/contexts/UnsavedChangesContext"; import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; import { stripBasePath, withBasePath } from "@app/constants/app"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; interface AppConfigModalProps { opened: boolean; @@ -193,7 +194,7 @@ const AppConfigModalInner: React.FC = ({ if (urlSync && location.pathname.startsWith("/settings")) { // "default" key = first entry (deep link/refresh); nothing to pop to. if (location.key === "default") { - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } else { navigate(-1); } diff --git a/frontend/editor/src/core/contexts/AdminTourOrchestrationContext.tsx b/frontend/editor/src/core/contexts/AdminTourOrchestrationContext.tsx index 5fd1b3f4c6..ff9b8b7037 100644 --- a/frontend/editor/src/core/contexts/AdminTourOrchestrationContext.tsx +++ b/frontend/editor/src/core/contexts/AdminTourOrchestrationContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useCallback, useRef } from "react"; import { useNavigate, useLocation } from "react-router-dom"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; interface AdminTourOrchestrationContextType { // State management @@ -40,8 +41,8 @@ export const AdminTourOrchestrationProvider: React.FC<{ savedLocationRef.current, ); - // Navigate back to saved location or home - const targetPath = savedLocationRef.current || "/"; + // Navigate back to saved location or the editor + const targetPath = savedLocationRef.current || EDITOR_BASENAME; navigate(targetPath, { replace: true }); savedLocationRef.current = ""; @@ -53,8 +54,8 @@ export const AdminTourOrchestrationProvider: React.FC<{ }, [navigate]); const closeConfigModal = useCallback(() => { - // Navigate back to home to close the modal - navigate("/", { replace: true }); + // Navigate back to the editor to close the modal + navigate(EDITOR_BASENAME, { replace: true }); }, [navigate]); const navigateToSection = useCallback( diff --git a/frontend/editor/src/core/hooks/useUrlSync.ts b/frontend/editor/src/core/hooks/useUrlSync.ts index ec4ede495d..5fad71ba29 100644 --- a/frontend/editor/src/core/hooks/useUrlSync.ts +++ b/frontend/editor/src/core/hooks/useUrlSync.ts @@ -12,6 +12,7 @@ import { import { ToolRegistry } from "@app/data/toolsTaxonomy"; import { firePixel } from "@app/utils/scarfTracking"; import { withBasePath } from "@app/constants/app"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { useAppConfig } from "@app/contexts/AppConfigContext"; /** @@ -35,7 +36,7 @@ export function useNavigationUrlSync( const tool = registry[toolId]; if (tool?.requiresPremium === true && premiumEnabled !== true) { // Premium tool accessed without premium - redirect to home - const homePath = withBasePath("/"); + const homePath = withBasePath(EDITOR_BASENAME); if (window.location.pathname !== homePath) { clearToolRoute(true); // Use replaceState to avoid adding to history window.location.href = homePath; @@ -81,7 +82,7 @@ export function useNavigationUrlSync( } else if (prevSelectedTool.current !== null) { // Only clear URL if we had a tool before (user navigated away) // Don't clear on initial load when both current and previous are null - const homePath = withBasePath("/"); + const homePath = withBasePath(EDITOR_BASENAME); if (window.location.pathname !== homePath) { clearToolRoute(false); // Use pushState for user navigation } diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 983d5213ed..88cbab8a7f 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -28,6 +28,7 @@ import FileManager from "@app/components/FileManager"; import LocalIcon from "@app/components/shared/LocalIcon"; import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; import { FilesPageProvider, @@ -116,7 +117,7 @@ export default function HomePage() { const handleCloseConfig = useCallback(() => { setConfigModalOpen(false); if (location.pathname.startsWith("/settings")) { - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } }, [location.pathname, navigate]); @@ -523,7 +524,7 @@ export default function HomePage() { } onToggleCollapse={() => { if (navigationState.workbench === "myFiles") { - navigate("/"); + navigate(EDITOR_BASENAME); return; } setFileSidebarCollapsed((c) => { diff --git a/frontend/editor/src/core/pages/MobileScannerPage.tsx b/frontend/editor/src/core/pages/MobileScannerPage.tsx index b1eef6d8d7..3dfce5509b 100644 --- a/frontend/editor/src/core/pages/MobileScannerPage.tsx +++ b/frontend/editor/src/core/pages/MobileScannerPage.tsx @@ -26,6 +26,7 @@ import { type JscanifyScanner, } from "@app/utils/loadJscanify"; import apiClient from "@app/services/apiClient"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; // Use the configured API base (e.g. api.stirling.com), not the page origin. const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, ""); @@ -890,7 +891,7 @@ export default function MobileScannerPage() { window.close(); // Fallback if window.close() doesn't work (some browsers block it) if (!window.closed) { - navigate("/"); + navigate(EDITOR_BASENAME); } }, 1500); } catch (err) { diff --git a/frontend/editor/src/core/routes/editorBasename.ts b/frontend/editor/src/core/routes/editorBasename.ts new file mode 100644 index 0000000000..a37f8d80ec --- /dev/null +++ b/frontend/editor/src/core/routes/editorBasename.ts @@ -0,0 +1,10 @@ +/** + * Path the editor app calls home - where "back to all tools" and every + * "return to the editor" navigation lands. + * + * Core ships no processor, so there is nothing for "/" to route between and the + * editor simply owns the root. Builds that DO ship a processor override this + * (see the proprietary copy): there "/" is a role-based router and the editor + * moves to its own URL. + */ +export const EDITOR_BASENAME = "/"; diff --git a/frontend/editor/src/core/tests/enterprise/license-and-features.spec.ts b/frontend/editor/src/core/tests/enterprise/license-and-features.spec.ts index c7f9bc367a..729a5fc298 100644 --- a/frontend/editor/src/core/tests/enterprise/license-and-features.spec.ts +++ b/frontend/editor/src/core/tests/enterprise/license-and-features.spec.ts @@ -22,7 +22,7 @@ async function uiLogin(page: import("@playwright/test").Page) { await page.locator("#email").fill(ADMIN); await page.locator("#password").fill(PASSWORD); await page.locator('button[type="submit"]').click(); - await page.waitForURL("/", { timeout: 15_000 }); + await page.waitForURL(/\/(editor|processor)/, { timeout: 15_000 }); await expect( page.locator('[data-testid="config-button"]').first(), ).toBeVisible({ timeout: 15_000 }); diff --git a/frontend/editor/src/core/tests/helpers/login.ts b/frontend/editor/src/core/tests/helpers/login.ts index c397141c9d..9fc60eaaaf 100644 --- a/frontend/editor/src/core/tests/helpers/login.ts +++ b/frontend/editor/src/core/tests/helpers/login.ts @@ -66,8 +66,8 @@ export async function login( // Click Sign In (the submit button inside the auth form) await page.locator('button[type="submit"]').click(); - // Wait for redirect to home - await page.waitForURL("/", { timeout: 15000 }); + // "/" routes by role; a signed-in user lands on the editor or the processor. + await page.waitForURL(/\/(editor|processor)/, { timeout: 15000 }); } /** diff --git a/frontend/editor/src/core/tests/helpers/stub-test-base.ts b/frontend/editor/src/core/tests/helpers/stub-test-base.ts index e6ae98c6f7..295d5b2184 100644 --- a/frontend/editor/src/core/tests/helpers/stub-test-base.ts +++ b/frontend/editor/src/core/tests/helpers/stub-test-base.ts @@ -54,7 +54,10 @@ const STUB_JWT = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdHViLXVzZXIifQ.signature"; export const test = base.extend({ stubOptions: [{}, { option: true }], - autoGoto: ["/", { option: true }], + // The editor's own URL, not "/". "/" is a role-based router that redirects, + // and /editor renders the editor in every flavour, so tests land straight on + // the app instead of racing a redirect on every single test. + autoGoto: ["/editor", { option: true }], seedJwt: [false, { option: true }], page: async ({ page, stubOptions, autoGoto, seedJwt }, use) => { diff --git a/frontend/editor/src/core/tests/live/authentication-login.spec.ts b/frontend/editor/src/core/tests/live/authentication-login.spec.ts index f9d0f6e628..1d3df58fa3 100644 --- a/frontend/editor/src/core/tests/live/authentication-login.spec.ts +++ b/frontend/editor/src/core/tests/live/authentication-login.spec.ts @@ -7,7 +7,7 @@ test.describe("1. Authentication and Login", () => { page, }) => { // Step 1: Verify the browser redirects to /login - await page.goto("/"); + await page.goto("/editor"); await expect(page).toHaveURL(/\/login/); // Step 2: Confirm the login page displays the Stirling PDF logo @@ -46,9 +46,9 @@ test.describe("1. Authentication and Login", () => { // Step 10: Click the "Sign In" button await signInButton.click(); - // Step 11: Verify the user is redirected to the home page at / - await page.waitForURL("/", { timeout: 15000 }); - await expect(page).toHaveURL("/"); + // Step 11: Verify "/" routes the user on to the editor + await page.waitForURL("/editor", { timeout: 15000 }); + await expect(page).toHaveURL("/editor"); // Step 12: Verify the home dashboard loads with tool sidebar and file upload area visible await expect( diff --git a/frontend/editor/src/core/tests/live/watched-folders.spec.ts b/frontend/editor/src/core/tests/live/watched-folders.spec.ts index 6ee7dddd7c..0021a4edf4 100644 --- a/frontend/editor/src/core/tests/live/watched-folders.spec.ts +++ b/frontend/editor/src/core/tests/live/watched-folders.spec.ts @@ -182,7 +182,7 @@ test.describe("Watched Folders — Presets", () => { const count1 = await getIDBFolderCount(page); // Navigate away and back - await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); await page.waitForSelector('[data-testid="watchedFolders-button"]', { timeout: 15000, }); @@ -421,7 +421,7 @@ test.describe("Watched Folders — Home Page", () => { sessionStorage.removeItem("watchedFolderHowItWorksDismissed"), ); // Re-navigate - await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); await page.waitForSelector('[data-testid="watchedFolders-button"]', { timeout: 15000, }); diff --git a/frontend/editor/src/core/tests/stubbed/audit-log-ui.spec.ts b/frontend/editor/src/core/tests/stubbed/audit-log-ui.spec.ts index 0e65631f4a..8033765f55 100644 --- a/frontend/editor/src/core/tests/stubbed/audit-log-ui.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/audit-log-ui.spec.ts @@ -73,7 +73,7 @@ async function setUpAdminWithAudit( ], }), ); - await page.goto("/"); + await page.goto("/editor"); } test.describe("Audit log UI", () => { diff --git a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts index 223a2eae51..856ddd58f5 100644 --- a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts @@ -46,7 +46,10 @@ test("a 10-file upload wave classifies every file into its group", async ({ await page.route("**/api/v1/policies/classify/meter", (route) => route.fulfill({ status: 202, body: "" }), ); - await page.goto("/", { waitUntil: "domcontentloaded", timeout: 120_000 }); + await page.goto("/editor", { + waitUntil: "domcontentloaded", + timeout: 120_000, + }); await uploadFiles( page, diff --git a/frontend/editor/src/core/tests/stubbed/cookie-preferences.spec.ts b/frontend/editor/src/core/tests/stubbed/cookie-preferences.spec.ts index 975238eba7..0b27171c54 100644 --- a/frontend/editor/src/core/tests/stubbed/cookie-preferences.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/cookie-preferences.spec.ts @@ -10,7 +10,7 @@ test.describe("18. Cookie Preferences", () => { await page.route("**/api/v1/ui-data/footer-info", (route) => route.fulfill({ json: { analyticsEnabled: true } }), ); - await page.goto("/"); + await page.goto("/editor"); // Step 1: The "Cookie Preferences" button lives in Settings → Legal await openSettings(page); diff --git a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts index 4c6e714ec0..ba33ea3c83 100644 --- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts @@ -647,10 +647,8 @@ test.describe("Files page", () => { await card.getByRole("button", { name: /File actions/i }).click(); await page.getByRole("menuitem", { name: /Add to workspace/i }).click(); // The materializer should have hit the download endpoint and - // routed the user to the viewer (/). - await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, { - timeout: 5_000, - }); + // routed the user to the viewer (the editor). + await expect(page).toHaveURL(/\/editor(\?|$)/, { timeout: 5_000 }); expect(downloadHit).toBe(true); }); @@ -703,9 +701,7 @@ test.describe("Files page", () => { // Open the card and confirm the share-link download endpoint fires. await card.getByRole("button", { name: /File actions/i }).click(); await page.getByRole("menuitem", { name: /Add to workspace/i }).click(); - await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, { - timeout: 5_000, - }); + await expect(page).toHaveURL(/\/editor(\?|$)/, { timeout: 5_000 }); expect(shareDownloadHit).toBe(true); }); diff --git a/frontend/editor/src/core/tests/stubbed/first-login-modal.spec.ts b/frontend/editor/src/core/tests/stubbed/first-login-modal.spec.ts index b00c84b364..0d1253694c 100644 --- a/frontend/editor/src/core/tests/stubbed/first-login-modal.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/first-login-modal.spec.ts @@ -60,7 +60,7 @@ async function setUpFirstLoginPage(page: Page) { test.describe("First-login forced password change modal", () => { test("modal renders with FirstLoginSlide content", async ({ page }) => { await setUpFirstLoginPage(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByText(/must change your password|set your password/i).first(), @@ -74,7 +74,7 @@ test.describe("First-login forced password change modal", () => { page, }) => { await setUpFirstLoginPage(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByText(/must change your password|set your password/i).first(), ).toBeVisible({ timeout: 15_000 }); @@ -100,7 +100,7 @@ test.describe("First-login forced password change modal", () => { }, ); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByText(/must change your password|set your password/i).first(), ).toBeVisible({ timeout: 15_000 }); diff --git a/frontend/editor/src/core/tests/stubbed/license-states.spec.ts b/frontend/editor/src/core/tests/stubbed/license-states.spec.ts index ab9256d50b..e72ab6e99f 100644 --- a/frontend/editor/src/core/tests/stubbed/license-states.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/license-states.spec.ts @@ -49,7 +49,7 @@ async function setUpAdminPage( await page.route("**/api/v1/admin/license-info", (route) => route.fulfill({ json: licenseInfo }), ); - await page.goto("/"); + await page.goto("/editor"); } test.describe("Admin license panel — state matrix", () => { diff --git a/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts b/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts index 2dccef4959..00779fe5a8 100644 --- a/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts @@ -53,7 +53,7 @@ test.describe("Login agreement modal", () => { page, }) => { await setUpLoggedIn(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByText("Login Agreement", { exact: true }).first(), @@ -69,7 +69,7 @@ test.describe("Login agreement modal", () => { test("Escape does not dismiss the modal (blocking)", async ({ page }) => { await setUpLoggedIn(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByRole("heading", { name: "Test Disclaimer" }), ).toBeVisible({ timeout: 15_000 }); @@ -85,7 +85,7 @@ test.describe("Login agreement modal", () => { page, }) => { await setUpLoggedIn(page); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByRole("heading", { name: "Test Disclaimer" }), ).toBeVisible({ timeout: 15_000 }); @@ -104,7 +104,7 @@ test.describe("Login agreement modal", () => { test("does not show when the feature is disabled", async ({ page }) => { await setUpLoggedIn(page, { enabled: false, content: "" }); - await page.goto("/"); + await page.goto("/editor"); // App is usable; modal never appears. await page.waitForTimeout(1500); await expect( @@ -117,7 +117,7 @@ test.describe("Login agreement modal", () => { await skipOnboarding(page); await mockAppApis(page, { enableLogin: false }); await stubDisclaimer(page, { showInAnonymousMode: true }); - await page.goto("/"); + await page.goto("/editor"); await expect( page.getByRole("heading", { name: "Test Disclaimer" }), @@ -129,7 +129,7 @@ test.describe("Login agreement modal", () => { await skipOnboarding(page); await mockAppApis(page, { enableLogin: false }); await stubDisclaimer(page, { showInAnonymousMode: false }); - await page.goto("/"); + await page.goto("/editor"); await page.waitForTimeout(1500); await expect( diff --git a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts index b8efb283fc..4c44c77d98 100644 --- a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts @@ -3,7 +3,7 @@ import { openSettings } from "@app/tests/helpers/ui-helpers"; test.describe("2. Main Dashboard / Home Page", () => { test.beforeEach(async ({ page }) => { - await page.goto("/"); + await page.goto("/editor"); }); test.describe("2.1 Dashboard Layout and Tool Categories", () => { @@ -80,7 +80,7 @@ test.describe("2. Main Dashboard / Home Page", () => { await expect(page).toHaveURL(/\/merge/, { timeout: 10000 }); - await page.goto("/"); + await page.goto("/editor"); // Tool search is a header toggle; the field mounts only once pressed. await expect( diff --git a/frontend/editor/src/core/tests/stubbed/navigation.spec.ts b/frontend/editor/src/core/tests/stubbed/navigation.spec.ts index 4fd6a6e341..0e47849b05 100644 --- a/frontend/editor/src/core/tests/stubbed/navigation.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/navigation.spec.ts @@ -36,18 +36,18 @@ test.describe("Navigation", () => { .getByRole("button", { name: /Back to all tools/i }) .first() .click(); - await expect(page).toHaveURL("/"); + await expect(page).toHaveURL("/editor"); await page.locator('a[href="/split"]').first().click(); await expect(page).toHaveURL(/\/split/); await page.goBack(); - await expect(page).toHaveURL("/"); + await expect(page).toHaveURL("/editor"); await page.goBack(); await expect(page).toHaveURL(/\/merge/); await page.goForward(); - await expect(page).toHaveURL("/"); + await expect(page).toHaveURL("/editor"); }); }); diff --git a/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts index f24e13f312..31c3accf47 100644 --- a/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts @@ -40,7 +40,10 @@ test.describe("PageEditor (multitool) rotation save", () => { test("rotating a page persists the correct absolute rotation on export", async ({ page, }) => { - await page.goto("/", { waitUntil: "domcontentloaded", timeout: 120_000 }); + await page.goto("/editor", { + waitUntil: "domcontentloaded", + timeout: 120_000, + }); await uploadFiles(page, ROTATED_PDF); // Enter the multitool via in-app navigation, NOT page.goto: a full reload // wipes the in-memory workbench before PageEditorContext's "entering page diff --git a/frontend/editor/src/core/tests/stubbed/premium-feature-gates.spec.ts b/frontend/editor/src/core/tests/stubbed/premium-feature-gates.spec.ts index 5c2f4a00a9..f9e0a089ed 100644 --- a/frontend/editor/src/core/tests/stubbed/premium-feature-gates.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/premium-feature-gates.spec.ts @@ -21,7 +21,7 @@ async function setUpEndpointAvailability( await seedCookieConsent(page); await bypassOnboarding(page); await mockAppApis(page, { endpointsAvailability: overrides }); - await page.goto("/"); + await page.goto("/editor"); } test.describe("Premium / endpoint gating", () => { @@ -74,7 +74,7 @@ test.describe("Premium / endpoint gating", () => { json: { username: "user", email: "user@example.com", isAdmin: false }, }), ); - await page.goto("/"); + await page.goto("/editor"); const configBtn = page.locator('[data-testid="config-button"]').first(); if (!(await configBtn.isVisible({ timeout: 5_000 }).catch(() => false))) { diff --git a/frontend/editor/src/core/tests/stubbed/seed.spec.ts b/frontend/editor/src/core/tests/stubbed/seed.spec.ts index 6b50e198aa..5cfb46c907 100644 --- a/frontend/editor/src/core/tests/stubbed/seed.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/seed.spec.ts @@ -56,7 +56,7 @@ export const TEST_FILES = { test.describe("Stirling-PDF seed", () => { test("seed - app loads", async ({ page }) => { // Navigate to the Stirling-PDF frontend - await page.goto("/"); + await page.goto("/editor"); // The app may redirect to /login if authentication is enabled. // Wait for the app to be ready: either the dashboard layout or the login page. diff --git a/frontend/editor/src/core/tests/stubbed/settings.spec.ts b/frontend/editor/src/core/tests/stubbed/settings.spec.ts index cb95a1171e..a9b0bbaa17 100644 --- a/frontend/editor/src/core/tests/stubbed/settings.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/settings.spec.ts @@ -156,7 +156,7 @@ test.describe("Settings dialog", () => { }; }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); await openSettings(page); const generalNav = page.locator('[data-tour="admin-general-nav"]').first(); @@ -200,7 +200,7 @@ test.describe("Settings dialog", () => { page, }) => { // Land on / first so the originating URL is unambiguous. - await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); await expect( page.locator('[data-testid="config-button"]').first(), ).toBeVisible({ timeout: 5_000 }); diff --git a/frontend/editor/src/core/tests/stubbed/teams-ui.spec.ts b/frontend/editor/src/core/tests/stubbed/teams-ui.spec.ts index 51ce8e2171..b7f20c9b12 100644 --- a/frontend/editor/src/core/tests/stubbed/teams-ui.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/teams-ui.spec.ts @@ -42,7 +42,7 @@ async function setUpAdminWithTeams( await page.route("**/api/v1/proprietary/ui-data/teams", (route) => route.fulfill({ json: teams }), ); - await page.goto("/"); + await page.goto("/editor"); } test.describe("Teams management UI", () => { diff --git a/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts b/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts index ae2e4b4e6d..d50ebd9b74 100644 --- a/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts @@ -44,7 +44,7 @@ test.describe("4. PDF Tool Pages - Common Patterns", () => { await homeLink.click(); // Step 3: Verify navigation back to the home dashboard - await expect(page).toHaveURL("/"); + await expect(page).toHaveURL("/editor"); // Step 4: Use browser back button await page.goBack(); diff --git a/frontend/editor/src/core/utils/urlRouting.ts b/frontend/editor/src/core/utils/urlRouting.ts index 3623dfceec..f8869067c2 100644 --- a/frontend/editor/src/core/utils/urlRouting.ts +++ b/frontend/editor/src/core/utils/urlRouting.ts @@ -13,6 +13,7 @@ import { import { firePixel } from "@app/utils/scarfTracking"; import { URL_TO_TOOL_MAP } from "@app/utils/urlMapping"; import { BASE_PATH, withBasePath } from "@app/constants/app"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; /** * Parse the current URL to extract tool routing information @@ -115,13 +116,13 @@ export function updateToolRoute( } /** - * Clear tool routing and return to home page + * Clear tool routing and return to the editor home ("/" is the role router). */ export function clearToolRoute(replace: boolean = false): void { const searchParams = new URLSearchParams(window.location.search); searchParams.delete("tool"); - updateUrl(withBasePath("/"), searchParams, replace); + updateUrl(withBasePath(EDITOR_BASENAME), searchParams, replace); } /** diff --git a/frontend/editor/src/desktop/routes/RootGate.tsx b/frontend/editor/src/desktop/routes/RootGate.tsx new file mode 100644 index 0000000000..01f55a092e --- /dev/null +++ b/frontend/editor/src/desktop/routes/RootGate.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +/** + * Desktop override: the desktop app ships no processor, so there is nothing for + * "/" to route between and the editor simply owns the root (see the desktop + * EDITOR_BASENAME). Pass straight through. + */ +export function RootGate({ children }: { children: ReactNode }) { + return <>{children}; +} + +export default RootGate; diff --git a/frontend/editor/src/desktop/routes/editorBasename.ts b/frontend/editor/src/desktop/routes/editorBasename.ts new file mode 100644 index 0000000000..62eda4ba6e --- /dev/null +++ b/frontend/editor/src/desktop/routes/editorBasename.ts @@ -0,0 +1,6 @@ +/** + * Desktop override: the desktop app ships no processor, so it takes core's + * behaviour back off the proprietary layer - nothing for "/" to route between, + * and the editor owns the root. + */ +export const EDITOR_BASENAME = "/"; diff --git a/frontend/editor/src/portal/auth/editorUrl.ts b/frontend/editor/src/portal/auth/editorUrl.ts index a7d92f0eb5..30c6a5b182 100644 --- a/frontend/editor/src/portal/auth/editorUrl.ts +++ b/frontend/editor/src/portal/auth/editorUrl.ts @@ -1,4 +1,5 @@ import { withBasePath } from "@app/constants/app"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; const CONFIGURED_EDITOR_URL = import.meta.env.VITE_EDITOR_URL || ""; @@ -17,13 +18,15 @@ export const EDITOR_IS_SAME_APP = * download-editor modal). * * Sourced from VITE_EDITOR_URL so it's configurable per deploy. When unset (the - * editor is this same app at the origin root), it resolves to the deploy's base - * path — so a subpath deploy (RUN_SUBPATH=/app → served under /app) lands on - * /app/ rather than the origin root. These CTAs use window.location, which - * bypasses the router basename, so the base path has to be baked in here. For - * dev cross-app navigation to a separately-running editor, set VITE_EDITOR_URL - * in editor/.env.local. + * editor is this same app at the origin root), it resolves to the editor's own + * route under the deploy's base path — so a subpath deploy (RUN_SUBPATH=/app → + * served under /app) lands on /app/editor rather than the origin root. These + * CTAs use window.location, which bypasses the router basename, so the base + * path has to be baked in here. Targeting EDITOR_BASENAME rather than "/" also + * keeps the switch out of the role-based router, which would bounce a processor + * user straight back. For dev cross-app navigation to a separately-running + * editor, set VITE_EDITOR_URL in editor/.env.local. */ export const EDITOR_URL = EDITOR_IS_SAME_APP - ? withBasePath("/") + ? withBasePath(EDITOR_BASENAME) : CONFIGURED_EDITOR_URL; diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 54a66a8e1a..ebf91c636e 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { CloseIcon, SettingsIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -49,7 +50,7 @@ export function Sidebar() { // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) // needs a full page load. const goToEditor = () => { - if (EDITOR_IS_SAME_APP) navigate("/"); + if (EDITOR_IS_SAME_APP) navigate(EDITOR_BASENAME); else window.location.href = EDITOR_URL; }; diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index de828c9f2d..c64935bb16 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -18,7 +18,7 @@ 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"; +import { RootGate } from "@app/routes/RootGate"; // Import global styles import "@app/styles/tailwind.css"; @@ -84,31 +84,34 @@ export default function App() { before the catch-all. Absent from core/desktop builds (empty stub). */} {getAdminRouteExtensions()} - {/* All other routes need AppProviders for backend integration */} + {/* All other routes need AppProviders for backend integration. + RootGate makes "/" route by role BEFORE any of it mounts, so a user + bound for the processor never boots the editor on the way. */} - - - - } /> - {/* Self-hosted has no signup - accounts are created by an - admin. Old links land on login instead. */} - } - /> - } /> - } /> - } /> - {/* Main app routes - Landing handles auth logic */} - } /> - - - {WATCHED_FOLDERS_ENABLED && } - - + + + + + } /> + {/* Self-hosted has no signup - accounts are created by an + admin. Old links land on login instead. */} + } + /> + } /> + } /> + } /> + {/* The editor and its tool routes - Landing handles auth logic */} + } /> + + + {WATCHED_FOLDERS_ENABLED && } + + + } /> diff --git a/frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx b/frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx deleted file mode 100644 index dd121ac77a..0000000000 --- a/frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { StrictMode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router-dom"; - -// Mutable holders driven per-test; read at call time by the mocks below. -const h = vi.hoisted(() => ({ - auth: { session: null as unknown, isAnonymous: false }, - prefs: { loginLandingView: "processor" as "processor" | "editor" }, - get: vi.fn(), -})); - -vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } })); -vi.mock("@app/auth/UseSession", () => ({ useAuth: () => h.auth })); -vi.mock("@app/contexts/PreferencesContext", () => ({ - usePreferences: () => ({ preferences: h.prefs, updatePreference: vi.fn() }), -})); - -import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; -import { - hasLoginLandingPending, - markLoginLandingPending, -} from "@app/utils/loginLanding"; - -function httpError(status: number) { - return Object.assign(new Error("http"), { response: { status } }); -} - -// Configure the two backend endpoints. teamMy === "404" simulates self-hosted. -function backend(opts: { - role: string; - portalAccess: boolean; - teamMy: unknown[] | "404"; -}) { - h.get.mockImplementation((url: string) => { - if (url === "/api/v1/auth/me") { - return Promise.resolve({ - data: { user: { role: opts.role, portalAccess: opts.portalAccess } }, - }); - } - if (url === "/api/v1/team/my") { - return opts.teamMy === "404" - ? Promise.reject(httpError(404)) - : Promise.resolve({ data: opts.teamMy }); - } - return Promise.resolve({ data: {} }); - }); -} - -function LocationProbe() { - return
{useLocation().pathname}
; -} - -function renderAt(pathname = "/", strict = false) { - const tree = ( - - - - - ); - return render(strict ? {tree} : tree); -} - -const SIGNED_IN = { session: { user: { id: "u1" } }, isAnonymous: false }; - -beforeEach(() => { - window.sessionStorage.clear(); - vi.stubEnv("VITE_INCLUDE_PORTAL", "true"); - vi.stubEnv("VITE_LOGIN_LANDING_MODE", "dynamic"); - h.auth = { ...SIGNED_IN }; - h.prefs = { loginLandingView: "processor" }; - h.get.mockReset(); -}); - -afterEach(() => vi.unstubAllEnvs()); - -describe("LoginLandingRedirect", () => { - it("self-hosted admin (no /team/my, portalAccess) → processor", async () => { - backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); - markLoginLandingPending(); - renderAt("/"); - await waitFor(() => - expect(screen.getByTestId("pathname").textContent).toBe("/processor"), - ); - expect(hasLoginLandingPending()).toBe(false); - }); - - it("self-hosted member (no /team/my, no portalAccess) → editor", async () => { - backend({ role: "USER", portalAccess: false, teamMy: "404" }); - markLoginLandingPending(); - renderAt("/"); - await waitFor(() => expect(hasLoginLandingPending()).toBe(false)); - expect(screen.getByTestId("pathname").textContent).toBe("/"); - }); - - it("saas real team lead → processor", async () => { - backend({ - role: "USER", - portalAccess: true, - teamMy: [{ isLeader: true, isPersonal: false }], - }); - markLoginLandingPending(); - renderAt("/"); - await waitFor(() => - expect(screen.getByTestId("pathname").textContent).toBe("/processor"), - ); - }); - - it("saas member → editor", async () => { - backend({ - role: "USER", - portalAccess: true, - teamMy: [ - { isLeader: true, isPersonal: true }, - { isLeader: false, isPersonal: false }, - ], - }); - markLoginLandingPending(); - renderAt("/"); - await waitFor(() => expect(hasLoginLandingPending()).toBe(false)); - expect(screen.getByTestId("pathname").textContent).toBe("/"); - }); - - it("still redirects under StrictMode double-invoke", async () => { - backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); - markLoginLandingPending(); - renderAt("/", true); - await waitFor(() => - expect(screen.getByTestId("pathname").textContent).toBe("/processor"), - ); - }); - - it("does not fetch when a user opted into the editor", async () => { - backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); - h.prefs = { loginLandingView: "editor" }; - markLoginLandingPending(); - renderAt("/"); - await Promise.resolve(); - expect(h.get).not.toHaveBeenCalled(); - expect(screen.getByTestId("pathname").textContent).toBe("/"); - expect(hasLoginLandingPending()).toBe(false); - }); - - it("does nothing in editor mode (soft release)", async () => { - vi.stubEnv("VITE_LOGIN_LANDING_MODE", "editor"); - backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); - markLoginLandingPending(); - renderAt("/"); - await Promise.resolve(); - expect(h.get).not.toHaveBeenCalled(); - expect(screen.getByTestId("pathname").textContent).toBe("/"); - }); - - it("does nothing without the fresh-login flag", async () => { - backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); - renderAt("/"); - await Promise.resolve(); - expect(h.get).not.toHaveBeenCalled(); - expect(screen.getByTestId("pathname").textContent).toBe("/"); - }); - - it("waits on auth routes and keeps the flag", async () => { - backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); - markLoginLandingPending(); - renderAt("/login"); - await Promise.resolve(); - expect(h.get).not.toHaveBeenCalled(); - expect(hasLoginLandingPending()).toBe(true); - }); - - it("ignores anonymous sessions", async () => { - h.auth = { session: { user: { id: "anon" } }, isAnonymous: true }; - backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); - markLoginLandingPending(); - renderAt("/"); - await Promise.resolve(); - expect(h.get).not.toHaveBeenCalled(); - expect(screen.getByTestId("pathname").textContent).toBe("/"); - }); -}); diff --git a/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx b/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx deleted file mode 100644 index 1df1995d02..0000000000 --- a/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useEffect, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useAuth } from "@app/auth/UseSession"; -import { usePreferences } from "@app/contexts/PreferencesContext"; -import { isAuthRoute } from "@app/constants/routes"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; -import { LoadingFallback } from "@app/components/shared/LoadingFallback"; -import { Z_INDEX_SIGN_IN_MODAL } from "@app/styles/zIndex"; -import { - consumeLoginLandingPending, - fetchLandsOnProcessor, - hasLoginLandingPending, - isPortalAvailable, - loginLandingMode, -} from "@app/utils/loginLanding"; - -/** - * On a fresh sign-in (any flavor), sends processor users to the processor and - * everyone else to the editor. Fires once per login, guarded by a sessionStorage - * flag set at login and consumed only once the destination is decided, so it - * never hijacks later in-session navigation. Gated by the VITE_LOGIN_LANDING_MODE - * soft-release flag ("dynamic" to enable). - * - * The decision (see fetchLandsOnProcessor) is driven by the shared /api/v1/auth/me - * so self-hosted and SaaS share one code path. While the lookup is in flight for - * a would-be processor user, a full-screen loader is shown so the editor never - * flashes before the redirect resolves. - * - * Mounted once (in AppProviders) for every flavor; not on the portal route-set, - * which is a separate top-level route. - */ -export function LoginLandingRedirect() { - const navigate = useNavigate(); - const location = useLocation(); - const { session, isAnonymous } = useAuth(); - const { preferences } = usePreferences(); - // A settled, non-anonymous session. Depend on this boolean rather than the - // session object so the effect - and its in-flight lookup - is not torn down - // by the identity churn of setSession() firing on every auth event. - const isSignedIn = !!session && !isAnonymous; - const landingView = preferences.loginLandingView; - const [resolving, setResolving] = useState(false); - - // One-time config log so a live instance reveals the silent build gates - // (soft-release mode off, or portal not bundled) even before any login. - useEffect(() => { - console.debug("[login-landing] config", { - mode: loginLandingMode(), - portalAvailable: isPortalAvailable(), - basename: PORTAL_BASENAME, - }); - }, []); - - useEffect(() => { - // Soft-release flag: outside "dynamic" nobody is auto-routed to the processor. - if (loginLandingMode() !== "dynamic") return; - // The fresh-login flag is the single source of truth for "once per login". It - // is consumed only at the decision below, so a re-run before then just retries - // (StrictMode double-invoke, or a dependency change mid-lookup) instead of - // dropping the redirect with the flag already spent. - if (!hasLoginLandingPending()) return; - console.debug("[login-landing] pending", { - isSignedIn, - onAuthRoute: isAuthRoute(location.pathname), - portalAvailable: isPortalAvailable(), - landingView, - path: location.pathname, - }); - if (!isSignedIn) return; - // Let the normal post-login navigation settle off the auth pages first. - if (isAuthRoute(location.pathname)) return; - - let active = true; - const settle = (goToProcessor: boolean) => { - // Ignore a stale attempt cancelled by a re-run; the live run will decide. - if (!active) return; - consumeLoginLandingPending(); - setResolving(false); - if (goToProcessor) navigate(PORTAL_BASENAME, { replace: true }); - }; - - // A user who chose "editor" opts out, and no processor to route to - - // decide synchronously, no lookup needed. - if (landingView === "editor" || !isPortalAvailable()) { - settle(false); - return; - } - - setResolving(true); - void fetchLandsOnProcessor().then((goToProcessor) => { - console.debug("[login-landing] decision", { goToProcessor }); - settle(goToProcessor); - }); - - return () => { - active = false; - setResolving(false); - }; - }, [isSignedIn, landingView, location.pathname, navigate]); - - // Cover the editor while a would-be-processor lookup resolves, so a lead never - // sees the editor flash before being sent to the processor. - if (resolving) { - return ( -
- -
- ); - } - return null; -} - -export default LoginLandingRedirect; diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx index e4c510458d..1b20297eec 100644 --- a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx @@ -5,7 +5,7 @@ import { SegmentedControl } from "@app/ui/SegmentedControl"; import { usePreferences } from "@app/contexts/PreferencesContext"; import type { LoginLandingView } from "@app/services/preferencesService"; import { - fetchLandsOnProcessor, + fetchRootDestination, isPortalAvailable, loginLandingMode, } from "@app/utils/loginLanding"; @@ -13,7 +13,7 @@ import { /** * Processor-user preference: where to land after signing in (processor vs * editor). Shown only to users who default to the processor (see - * fetchLandsOnProcessor); hidden for members and solo users. Shared by all + * fetchRootDestination); hidden for members and solo users. Shared by all * flavors. */ export function LoginLandingSetting() { @@ -27,8 +27,8 @@ export function LoginLandingSetting() { useEffect(() => { if (!active) return; let cancelled = false; - void fetchLandsOnProcessor().then((v) => { - if (!cancelled) setEligible(v); + void fetchRootDestination().then((destination) => { + if (!cancelled) setEligible(destination === "processor"); }); return () => { cancelled = true; diff --git a/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts b/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts index 18b028ab8e..4d2d7fbdd4 100644 --- a/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts +++ b/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts @@ -13,6 +13,7 @@ import { import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders"; import { slugify } from "@app/utils/slug"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; // Inlined to avoid circular imports — must match WatchedFoldersRegistration.tsx const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; @@ -160,7 +161,7 @@ export function useWatchedFolderUrlSync() { window.history.pushState(null, "", targetPath); } } else if (prevIsWatchedFolder.current && isWatchedFolderUrl()) { - window.history.pushState(null, "", withBasePath("/")); + window.history.pushState(null, "", withBasePath(EDITOR_BASENAME)); } prevIsWatchedFolder.current = isWatchedFolderWorkbench; }, [isWatchedFolderWorkbench, folderId, idToSlug]); diff --git a/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx b/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx index 53b838a0d3..bcc9644584 100644 --- a/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx +++ b/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx @@ -85,8 +85,8 @@ describe("AuthCallback", () => { // Verify getSession was called to validate token expect(springAuth.getSession).toHaveBeenCalled(); - // Verify navigation to home - expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); + // Verify it lands on the editor (no processor access in this build) + expect(mockNavigate).toHaveBeenCalledWith("/editor", { replace: true }); }); }); @@ -202,7 +202,7 @@ describe("AuthCallback", () => { expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBeNull(); }); - it("should fall back to home when the stored post-login path is unsafe", async () => { + it("should fall back to the editor when the stored post-login path is unsafe", async () => { const mockToken = "oauth-jwt-token"; const mockUser = { id: "123", @@ -234,7 +234,7 @@ describe("AuthCallback", () => { ); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); + expect(mockNavigate).toHaveBeenCalledWith("/editor", { replace: true }); }); expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBeNull(); }); diff --git a/frontend/editor/src/proprietary/routes/AuthCallback.tsx b/frontend/editor/src/proprietary/routes/AuthCallback.tsx index 4ce0e4b7aa..8c62fcdb32 100644 --- a/frontend/editor/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/editor/src/proprietary/routes/AuthCallback.tsx @@ -1,11 +1,11 @@ import { useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; +import { resolveLandingPath } from "@app/utils/loginLanding"; import { useTranslation } from "react-i18next"; import { consumePostLoginRedirectPath, springAuth, } from "@app/auth/spring/springAuthClient"; -import { markLoginLandingPending } from "@app/utils/loginLanding"; import { handleAuthCallbackSuccess } from "@app/extensions/authCallback"; import { AuthShell } from "@app/auth/ui/AuthShell"; import { Spinner } from "@app/ui/Spinner"; @@ -100,10 +100,10 @@ export default function AuthCallback() { // This prevents infinite render loop when coming from cross-domain SAML redirect await new Promise((resolve) => setTimeout(resolve, 100)); - const target = consumePostLoginRedirectPath() ?? "/"; - // Fresh OAuth/SSO login with no explicit destination: let the role-based - // landing route processor users. - if (target === "/") markLoginLandingPending(); + // No explicit destination: land processor users on the processor and + // everyone else on the editor. + const target = + consumePostLoginRedirectPath() ?? (await resolveLandingPath()); console.info( `[AuthCallback] Authenticated ${data.session.user.username} in ${elapsed()}, navigating to ${target}`, ); diff --git a/frontend/editor/src/proprietary/routes/Landing.tsx b/frontend/editor/src/proprietary/routes/Landing.tsx index acb6a6e9c8..62c9083aad 100644 --- a/frontend/editor/src/proprietary/routes/Landing.tsx +++ b/frontend/editor/src/proprietary/routes/Landing.tsx @@ -4,6 +4,7 @@ import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import HomePage from "@app/pages/HomePage"; import { useBackendProbe } from "@app/hooks/useBackendProbe"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import AuthLayout from "@app/routes/authShared/AuthLayout"; import LoginHeader from "@app/routes/login/LoginHeader"; import { useTranslation } from "react-i18next"; @@ -59,7 +60,7 @@ export default function Landing() { if (result.status === "up") { await refetch(); if (result.loginDisabled) { - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } } }; @@ -130,7 +131,7 @@ export default function Landing() { const result = await backendProbe.probe(); if (result.status === "up") { await refetch(); - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } }; return ( @@ -173,10 +174,17 @@ export default function Landing() { return ; } - // No session - redirect to login page - // This ensures the URL always shows /login when not authenticated + // No session - redirect to login page. The URL always shows /login when not + // authenticated, and carries where we came from so signing in returns there + // (going to /editor and logging in lands back on /editor, not the role + // router). Also passed as router state; the query is what survives a reload. + const returnTo = encodeURIComponent(location.pathname + location.search); return config?.enableLogin === true && !backendProbe.loginDisabled ? ( - + ) : ( ); diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index 31e74e533b..d2cb80360f 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { BrowserRouter, MemoryRouter } from "react-router-dom"; @@ -156,7 +156,7 @@ describe("Login", () => { }); }); - it("should redirect authenticated user to home", async () => { + it("should land an authenticated user on the editor", async () => { const mockSession = { user: { id: "123", @@ -191,7 +191,77 @@ describe("Login", () => { ); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); + expect(mockNavigate).toHaveBeenCalledWith("/editor", { replace: true }); + }); + }); + + // Landing bounces an unauthenticated visitor to /login?from=, + // so signing in returns them there instead of re-running the role routing. + describe("return path", () => { + const signedIn = () => { + const mockSession = { + user: { + id: "123", + email: "test@example.com", + username: "testuser", + role: "USER", + }, + access_token: "mock-token", + expires_in: 3600, + }; + vi.mocked(useAuth).mockReturnValue({ + session: mockSession, + user: mockSession.user, + displayName: mockSession.user.username, + isAnonymous: false, + isAdmin: false, + portalAccess: false, + role: mockSession.user.role, + loading: false, + error: null, + signOut: vi.fn(), + refreshSession: vi.fn(), + }); + }; + + const renderAtLogin = (search: string) => { + window.history.replaceState({}, "", `/login${search}`); + return render( + + + + + , + ); + }; + + afterEach(() => window.history.replaceState({}, "", "/")); + + it("returns to where the user came from", async () => { + signedIn(); + renderAtLogin(`?from=${encodeURIComponent("/compress")}`); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/compress", { + replace: true, + }); + }); + }); + + // Delegated to the shared isSafePostLoginRedirect, so the backslash form + // (browsers normalise "\" to "/") and auth routes are covered too. + it.each([ + ["protocol-relative", "//evil.example.com"], + ["backslash-escaped", "/\\evil.example.com"], + ["an auth route", "/login"], + ])("rejects %s and lands normally", async (_label, from) => { + signedIn(); + renderAtLogin(`?from=${encodeURIComponent(from)}`); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/editor", { replace: true }); + }); + expect(mockNavigate).not.toHaveBeenCalledWith(from, expect.anything()); }); }); @@ -580,7 +650,7 @@ describe("Login", () => { }); }); - it("should redirect to home when login disabled", async () => { + it("should redirect to the editor when login disabled", async () => { mockBackendProbeState.loginDisabled = true; mockProbe.mockResolvedValueOnce({ status: "up", @@ -603,7 +673,7 @@ describe("Login", () => { ); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); + expect(mockNavigate).toHaveBeenCalledWith("/editor", { replace: true }); }); }); diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index f4247bfca5..b134d9ee89 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -6,14 +6,16 @@ import { useSearchParams, } from "react-router-dom"; import { Button } from "@app/ui/Button"; +import { isSafePostLoginRedirect } from "@app/auth"; import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; -import { markLoginLandingPending } from "@app/utils/loginLanding"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useTranslation } from "react-i18next"; import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; import AuthLayout from "@app/routes/authShared/AuthLayout"; import { useBackendProbe } from "@app/hooks/useBackendProbe"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { resolveLandingPath } from "@app/utils/loginLanding"; import { BASE_PATH, withBasePath } from "@app/constants/app"; import { updateSupportedLanguages } from "@app/i18n"; import SpringLoginForm from "@app/auth/ui/SpringLoginForm"; @@ -27,17 +29,27 @@ export default function Login() { const location = useLocation(); const [searchParams] = useSearchParams(); const { session, loading } = useAuth(); + // Reuses the shared guard rather than re-deriving one: same-origin relative + // paths only, rejecting "//host", "/\host" (browsers normalise the backslash) + // and auth routes (a ?from=/login would cost a pointless hop back here). + const safePath = (path: unknown): string | null => + isSafePostLoginRedirect(path) ? path : null; + + // Where to return to after signing in. Router state first (set when Landing + // bounces an unauthenticated visitor), then the query, which is what survives + // a reload of /login. Null means "no specific destination" and the caller + // falls back to role-based landing. const resolveReturnPath = (): string | null => { const fromState = ( location.state as { from?: { pathname?: string } } | null )?.from?.pathname; - if (fromState) return fromState; + if (fromState) return safePath(fromState); const fromQuery = searchParams.get("from"); if (!fromQuery) return null; try { - return decodeURIComponent(fromQuery); + return safePath(decodeURIComponent(fromQuery)); } catch { - return fromQuery; + return safePath(fromQuery); } }; const { refetch } = useAppConfig(); @@ -53,9 +65,6 @@ export default function Login() { backendProbe.loginDisabled === true || _enableLogin === false; const autoLoginAttempted = useRef(false); const autoLoginErrorRecorded = useRef(false); - // True once we've observed a signed-out state on this page, so we can tell a - // fresh login (arrived signed-out, then signed in) from an already-authed visit. - const sawSignedOutRef = useRef(false); const AUTO_LOGIN_ATTEMPTS_KEY = "stirling_sso_auto_login_attempts"; const AUTO_LOGIN_ERRORS_KEY = "stirling_sso_auto_login_errors"; @@ -149,8 +158,8 @@ export default function Login() { onConfigLoaded: (data) => { // If login is disabled, redirect to home (anonymous mode) if (data.enableLogin === false) { - console.debug("[Login] Login disabled, redirecting to home"); - navigate("/"); + console.debug("[Login] Login disabled, going to the editor"); + navigate(EDITOR_BASENAME); return; } setEnableLogin(data.enableLogin ?? true); @@ -177,7 +186,7 @@ export default function Login() { if (result.status === "up") { await refetch(); if (loginDisabled) { - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } } }; @@ -197,28 +206,35 @@ export default function Login() { // Redirect immediately if user has valid session (JWT already validated by AuthProvider) useEffect(() => { if (loading) return; - if (!session) { - sawSignedOutRef.current = true; + if (!session) return; + const returnPath = resolveReturnPath(); + if (returnPath) { + navigate(returnPath, { replace: true }); return; } - const returnPath = resolveReturnPath(); - // Fresh form login (we were signed out on this page) with no explicit - // destination: let the role-based landing route processor users. An - // already-authed visit to /login never sets the flag. - if (sawSignedOutRef.current && !returnPath) { - markLoginLandingPending(); - } - console.debug("[Login] User already authenticated, redirecting to home", { - returnPath, + // No explicit destination: land processor users on the processor and + // everyone else on the editor. Resolved here rather than by bouncing + // through "/" so the app isn't torn down and remounted on the way. + let active = true; + void resolveLandingPath().then((path) => { + if (!active) return; + console.debug("[Login] Authenticated, landing on", path); + navigate(path, { replace: true }); }); - navigate(returnPath || "/", { replace: true }); + return () => { + active = false; + }; }, [session, loading, navigate, location.state, searchParams]); // If backend reports login is disabled, redirect to home (anonymous mode) useEffect(() => { if (backendProbe.loginDisabled) { - // Slight delay to allow state updates before redirecting - const id = setTimeout(() => navigate("/", { replace: true }), 0); + // Straight to the editor, not "/": with login disabled there is no role + // to route on, and "/" would just bounce back here. + const id = setTimeout( + () => navigate(EDITOR_BASENAME, { replace: true }), + 0, + ); return () => clearTimeout(id); } }, [backendProbe.loginDisabled, navigate]); @@ -384,7 +400,7 @@ export default function Login() { // If login is disabled, short-circuit to home (avoids rendering the form after retry) if (loginDisabled) { - return ; + return ; } // Show logged in state if authenticated diff --git a/frontend/editor/src/proprietary/routes/RootGate.test.tsx b/frontend/editor/src/proprietary/routes/RootGate.test.tsx new file mode 100644 index 0000000000..aeb1065f03 --- /dev/null +++ b/frontend/editor/src/proprietary/routes/RootGate.test.tsx @@ -0,0 +1,157 @@ +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; + +// Mutable holders driven per-test; read at call time by the mocks below. +const h = vi.hoisted(() => ({ + landingView: "processor" as "processor" | "editor", + get: vi.fn(), +})); + +vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } })); +vi.mock("@app/services/preferencesService", () => ({ + preferencesService: { getPreference: () => h.landingView }, +})); + +import { RootGate } from "@app/routes/RootGate"; + +function httpError(status: number) { + return { isAxiosError: true, message: "http", response: { status } }; +} + +// Configure the two backend endpoints. teamMy === "404" simulates self-hosted. +function backend(opts: { + role: string; + portalAccess: boolean; + teamMy: unknown[] | "404"; +}) { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") { + return Promise.resolve({ + data: { user: { role: opts.role, portalAccess: opts.portalAccess } }, + }); + } + if (url === "/api/v1/team/my") { + return opts.teamMy === "404" + ? Promise.reject(httpError(404)) + : Promise.resolve({ data: opts.teamMy }); + } + return Promise.resolve({ data: {} }); + }); +} + +function LocationProbe() { + return
{useLocation().pathname}
; +} + +/** Stands in for the whole app tree, so "did the app mount?" is observable. */ +function TheApp() { + return
the app
; +} + +// Mirrors App.tsx: the processor is a sibling top-level route, so RootGate (the +// catch-all element) is not even rendered once the redirect lands. +function renderAt(pathname: string, strict = false) { + const tree = ( + + + the processor} + /> + + + + } + /> + + + + ); + return render(strict ? {tree} : tree); +} + +const at = () => screen.getByTestId("pathname").textContent; +const appMounted = () => screen.queryByTestId("app") !== null; + +describe("RootGate", () => { + beforeEach(() => { + h.landingView = "processor"; + h.get.mockReset(); + // The portal only exists in some builds; the decision is a no-op without it. + vi.stubEnv("VITE_INCLUDE_PORTAL", "true"); + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "dynamic"); + }); + afterEach(() => vi.unstubAllEnvs()); + + it("renders the app untouched on every path but /", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + renderAt("/compress"); + expect(appMounted()).toBe(true); + expect(at()).toBe("/compress"); + expect(h.get).not.toHaveBeenCalled(); + }); + + it("sends a processor user to the processor", async () => { + backend({ role: "USER", portalAccess: true, teamMy: "404" }); + renderAt("/"); + await waitFor(() => expect(at()).toBe("/processor")); + }); + + it("sends everyone else to the editor", async () => { + backend({ role: "USER", portalAccess: false, teamMy: "404" }); + renderAt("/"); + await waitFor(() => expect(at()).toBe("/editor")); + }); + + it("never boots the app on the way to the processor", async () => { + backend({ role: "USER", portalAccess: true, teamMy: "404" }); + renderAt("/"); + expect(appMounted()).toBe(false); // deciding + await waitFor(() => expect(at()).toBe("/processor")); + expect(appMounted()).toBe(false); + expect(screen.getByTestId("processor")).toBeTruthy(); + }); + + it("leaves a signed-out visitor on / and lets the app handle it", async () => { + h.get.mockRejectedValue(httpError(401)); + renderAt("/"); + await waitFor(() => expect(appMounted()).toBe(true)); + expect(at()).toBe("/"); + }); + + it("survives StrictMode's double-invoke", async () => { + backend({ role: "USER", portalAccess: true, teamMy: "404" }); + renderAt("/", true); + await waitFor(() => expect(at()).toBe("/processor")); + }); + + it("honours the per-user editor override without a lookup", async () => { + h.landingView = "editor"; + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + renderAt("/"); + await waitFor(() => expect(at()).toBe("/editor")); + expect(h.get).not.toHaveBeenCalled(); + }); + + it("routes everyone to the editor when the soft-release flag is off", async () => { + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "editor"); + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + renderAt("/"); + await waitFor(() => expect(at()).toBe("/editor")); + expect(h.get).not.toHaveBeenCalled(); + }); + + it("routes to the editor when this build ships no processor", async () => { + vi.stubEnv("VITE_INCLUDE_PORTAL", "false"); + vi.stubEnv("DEV", false); + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + renderAt("/"); + await waitFor(() => expect(at()).toBe("/editor")); + expect(h.get).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/proprietary/routes/RootGate.tsx b/frontend/editor/src/proprietary/routes/RootGate.tsx new file mode 100644 index 0000000000..3821bec429 --- /dev/null +++ b/frontend/editor/src/proprietary/routes/RootGate.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Navigate, useLocation } from "react-router-dom"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import { resolveRootTarget } from "@app/utils/loginLanding"; + +/** A decision, tagged with the visit it was made for so it can never go stale. */ +interface Decision { + visit: string; + /** Where to send them, or null for "no role to route on - render the app". */ + target: string | null; +} + +/** + * Makes "/" a role-based router instead of a page: signed-in users are sent on + * to the processor or the editor, with a per-user override in Settings and the + * VITE_LOGIN_LANDING_MODE soft-release flag. Every other path renders the app + * untouched. + * + * Wraps the app rather than being a route of its own, which buys two things: + * - The editor never boots just to be redirected away from. Mounting the + * decision inside the app booted IndexedDB, the file context, onboarding and + * the license fetch on the way to the processor, then tore it all down - the + * visible "editor flashes, then jumps" glitch. + * - Every other path shares one element position with the app, so navigating + * away from "/" is a plain render, not a remount. + * + * Signed-out visitors are NOT redirected: the app renders at "/" exactly as it + * always has, and Landing owns that experience (self-hosted bounces to /login, + * SaaS signs in inline, a downed backend gets the status screen). Only the app + * knows which of those applies, and short-cutting to /login from here would + * bounce a login-disabled or backend-down instance between the two. + * + * Note this only catches people who ARRIVE at "/". A fresh login resolves its + * own destination (see resolveLandingPath) rather than bouncing through here, + * so signing in never tears the app back down. + * + * Context-free by necessity, since it sits above the providers: apiClient + * resolves its own auth token and the per-user override is read straight from + * localStorage. Builds with no processor (core, desktop) use the pass-through + * override. + */ +export function RootGate({ children }: { children: ReactNode }) { + const location = useLocation(); + // useLocation is already basename-relative, so this holds under subpath deploys. + const isRoot = location.pathname === "/"; + const [decision, setDecision] = useState(null); + + useEffect(() => { + if (!isRoot) return; + let active = true; + void resolveRootTarget().then((target) => { + // Ignore a stale lookup cancelled by a re-run; the live one decides. + if (!active) return; + console.debug("[root-gate] decision", { target }); + setDecision({ visit: location.key, target }); + }); + return () => { + active = false; + }; + }, [isRoot, location.key]); + + if (!isRoot) return <>{children}; + // A decision from an earlier visit to "/" must not be reused: the answer can + // change (a sign-in happened since), and acting on the old one would flash the + // app before correcting itself. + if (decision?.visit !== location.key) return ; + if (decision.target === null) return <>{children}; + return ; +} + +export default RootGate; diff --git a/frontend/editor/src/proprietary/routes/ShareLinkLoader.tsx b/frontend/editor/src/proprietary/routes/ShareLinkLoader.tsx index 1984e55f2a..2edb8b52eb 100644 --- a/frontend/editor/src/proprietary/routes/ShareLinkLoader.tsx +++ b/frontend/editor/src/proprietary/routes/ShareLinkLoader.tsx @@ -10,6 +10,7 @@ import { alert } from "@app/components/toast"; import type { StirlingFile } from "@app/types/fileContext"; import type { FileId } from "@app/types/file"; import { fileStorage } from "@app/services/fileStorage"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { getShareBundleEntryRootId, isZipBundle, @@ -174,7 +175,7 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) { } navActions.setWorkbench("viewer"); - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); return; } } @@ -210,7 +211,7 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) { } navActions.setWorkbench("viewer"); - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } catch (error: unknown) { if (signal.aborted) return; const status = isAxiosError(error) ? error.response?.status : undefined; @@ -240,7 +241,7 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) { expandable: false, durationMs: 4500, }); - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } else if (status === 404 || status === 410) { alert({ alertType: "error", @@ -248,7 +249,7 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) { expandable: false, durationMs: 4000, }); - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } else { alert({ alertType: "error", diff --git a/frontend/editor/src/proprietary/routes/ShareLinkPage.tsx b/frontend/editor/src/proprietary/routes/ShareLinkPage.tsx index aef9b6f4f7..4cf09ea27c 100644 --- a/frontend/editor/src/proprietary/routes/ShareLinkPage.tsx +++ b/frontend/editor/src/proprietary/routes/ShareLinkPage.tsx @@ -20,6 +20,7 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import { useFileActions } from "@app/contexts/FileContext"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { alert } from "@app/components/toast"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { downloadShareLink, fetchShareLinkMetadata, @@ -143,7 +144,7 @@ export default function ShareLinkPage() { actions.setSelectedFiles(selectedIds); } navActions.setWorkbench("viewer"); - navigate("/", { replace: true }); + navigate(EDITOR_BASENAME, { replace: true }); } catch (error: unknown) { const statusCode = isAxiosError(error) ? error.response?.status diff --git a/frontend/editor/src/proprietary/routes/editorBasename.ts b/frontend/editor/src/proprietary/routes/editorBasename.ts new file mode 100644 index 0000000000..1f21d82e8a --- /dev/null +++ b/frontend/editor/src/proprietary/routes/editorBasename.ts @@ -0,0 +1,9 @@ +/** + * Proprietary/SaaS override: these builds ship the processor, so "/" is a + * role-based router (see RootRedirect) and the editor needs a URL of its own. + * + * Navigating here always lands on the editor - no role lookup, no redirect - + * which is what makes it the escape hatch for processor users who want the + * editor. Mirrors PORTAL_BASENAME ("/processor"). + */ +export const EDITOR_BASENAME = "/editor"; diff --git a/frontend/editor/src/proprietary/utils/loginLanding.test.ts b/frontend/editor/src/proprietary/utils/loginLanding.test.ts index 43a33e1a0d..4de65edec3 100644 --- a/frontend/editor/src/proprietary/utils/loginLanding.test.ts +++ b/frontend/editor/src/proprietary/utils/loginLanding.test.ts @@ -4,14 +4,10 @@ const h = vi.hoisted(() => ({ get: vi.fn() })); vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } })); import { - LOGIN_LANDING_PENDING_KEY, - consumeLoginLandingPending, - fetchLandsOnProcessor, - hasLoginLandingPending, + fetchRootDestination, isPortalAvailable, leadsRealTeam, loginLandingMode, - markLoginLandingPending, type LandingTeam, } from "@app/utils/loginLanding"; @@ -20,7 +16,7 @@ function team(o: Partial): LandingTeam { } // Axios-error-shaped plain object (not an Error instance) so the harness's -// uncaught-Error tracking doesn't flag the rejection that fetchLandsOnProcessor +// uncaught-Error tracking doesn't flag the rejection that fetchRootDestination // deliberately catches. function httpError(status: number) { return { isAxiosError: true, message: "http", response: { status } }; @@ -45,20 +41,6 @@ describe("leadsRealTeam", () => { }); }); -describe("login-landing pending flag", () => { - beforeEach(() => window.sessionStorage.clear()); - - it("marks, peeks, and consumes once", () => { - expect(hasLoginLandingPending()).toBe(false); - markLoginLandingPending(); - expect(window.sessionStorage.getItem(LOGIN_LANDING_PENDING_KEY)).toBe("1"); - expect(hasLoginLandingPending()).toBe(true); - expect(consumeLoginLandingPending()).toBe(true); - expect(hasLoginLandingPending()).toBe(false); - expect(consumeLoginLandingPending()).toBe(false); - }); -}); - describe("loginLandingMode", () => { afterEach(() => vi.unstubAllEnvs()); @@ -81,23 +63,23 @@ describe("isPortalAvailable", () => { }); }); -describe("fetchLandsOnProcessor", () => { +describe("fetchRootDestination", () => { beforeEach(() => h.get.mockReset()); - // fetchLandsOnProcessor calls /me first, then /team/my. mockRejectedValueOnce + // fetchRootDestination calls /me first, then /team/my. mockRejectedValueOnce // is vitest's rejection helper (tracks the rejection so it isn't flagged). it("self-hosted (no /team/my): uses portalAccess = true", async () => { h.get .mockResolvedValueOnce(mockMe("USER", true)) .mockRejectedValueOnce(httpError(404)); - expect(await fetchLandsOnProcessor()).toBe(true); + expect(await fetchRootDestination()).toBe("processor"); }); it("self-hosted (no /team/my): portalAccess false → editor", async () => { h.get .mockResolvedValueOnce(mockMe("USER", false)) .mockRejectedValueOnce(httpError(404)); - expect(await fetchLandsOnProcessor()).toBe(false); + expect(await fetchRootDestination()).toBe("editor"); }); it("saas: admin → processor even with only a personal team", async () => { @@ -108,7 +90,7 @@ describe("fetchLandsOnProcessor", () => { data: [team({ isLeader: true, isPersonal: true })], }); }); - expect(await fetchLandsOnProcessor()).toBe(true); + expect(await fetchRootDestination()).toBe("processor"); }); it("saas: non-admin real lead → processor", async () => { @@ -119,7 +101,7 @@ describe("fetchLandsOnProcessor", () => { data: [team({ isLeader: true, isPersonal: false })], }); }); - expect(await fetchLandsOnProcessor()).toBe(true); + expect(await fetchRootDestination()).toBe("processor"); }); it("saas: member → editor (ignores polluted portalAccess)", async () => { @@ -133,18 +115,18 @@ describe("fetchLandsOnProcessor", () => { ], }); }); - expect(await fetchLandsOnProcessor()).toBe(false); + expect(await fetchRootDestination()).toBe("editor"); }); - it("editor when /me fails", async () => { + it("signedOut when /me fails - not authenticated", async () => { h.get.mockRejectedValueOnce(httpError(401)); - expect(await fetchLandsOnProcessor()).toBe(false); + expect(await fetchRootDestination()).toBe("signedOut"); }); it("editor when /team/my fails with a non-404 (ambiguous)", async () => { h.get .mockResolvedValueOnce(mockMe("USER", true)) .mockRejectedValueOnce(httpError(500)); - expect(await fetchLandsOnProcessor()).toBe(false); + expect(await fetchRootDestination()).toBe("editor"); }); }); diff --git a/frontend/editor/src/proprietary/utils/loginLanding.ts b/frontend/editor/src/proprietary/utils/loginLanding.ts index ee36e4715e..f51ee65df6 100644 --- a/frontend/editor/src/proprietary/utils/loginLanding.ts +++ b/frontend/editor/src/proprietary/utils/loginLanding.ts @@ -1,12 +1,16 @@ import { isAdminRole } from "@app/auth/roles"; import apiClient from "@app/services/apiClient"; +import { preferencesService } from "@app/services/preferencesService"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; /** - * Role-based login landing, shared by every flavor (self-hosted + SaaS). + * Role-based landing, shared by every flavor (self-hosted + SaaS). * - * On a fresh sign-in, users who can use the processor (portal) land there; - * everyone else lands on the editor. The decision is driven by the shared - * `/api/v1/auth/me` endpoint so there is a single code path for all flavors: + * Backs the router at "/" (RootGate) and the destination a fresh login lands on: + * users who can use the processor (portal) are sent there, everyone else to the + * editor. The decision is driven by the shared `/api/v1/auth/me` endpoint so + * there is a single code path for all flavors: * * - Self-hosted: `portalAccess` from `/me` (admin, ACL grant, or team owner) is * the clean signal - there are no personal teams, and `/api/v1/team/my` does @@ -17,11 +21,6 @@ import apiClient from "@app/services/apiClient"; * team, which excludes members and solo/personal users. */ -// sessionStorage flag set at a genuine fresh login and consumed once when the -// user lands, so the redirect never hijacks later in-session navigation (e.g. -// switching back to the editor from the processor). -export const LOGIN_LANDING_PENDING_KEY = "stirling_login_landing_pending"; - export type LoginLandingMode = "editor" | "dynamic"; /** Minimal shape of a `/api/v1/team/my` row that the decision needs. */ @@ -55,48 +54,67 @@ export function loginLandingMode(): LoginLandingMode { : "dynamic"; } -/** Flag a genuine fresh login so the landing redirect fires exactly once. */ -export function markLoginLandingPending(): void { - try { - window.sessionStorage.setItem(LOGIN_LANDING_PENDING_KEY, "1"); - } catch { - // sessionStorage unavailable (private mode / SSR): skip the one-time redirect. - } -} - -/** Whether a fresh-login redirect is still pending (non-destructive peek). */ -export function hasLoginLandingPending(): boolean { - try { - return window.sessionStorage.getItem(LOGIN_LANDING_PENDING_KEY) === "1"; - } catch { - return false; - } -} - -/** Clear the pending flag; returns whether it was set. */ -export function consumeLoginLandingPending(): boolean { - try { - const pending = - window.sessionStorage.getItem(LOGIN_LANDING_PENDING_KEY) === "1"; - if (pending) window.sessionStorage.removeItem(LOGIN_LANDING_PENDING_KEY); - return pending; - } catch { - return false; - } -} - interface MeUser { role?: string; portalAccess?: boolean; } /** - * Whether the signed-in user should land on the processor. One decision for all - * flavors: fetch the shared `/api/v1/auth/me`, then branch on whether - * `/api/v1/team/my` exists (SaaS) or 404s (self-hosted). Any failure defaults to - * the editor (safe). Best-effort - callers treat a thrown/false result as editor. + * Where a visitor to "/" belongs. + * + * `signedOut` is deliberately distinct from `editor`: there is no role to route + * on, so "/" hands off to the app itself rather than redirecting. Only the app + * knows the whole picture (login enabled? backend up? SaaS inline sign-in?), and + * guessing from here risks bouncing a user between "/" and "/login". */ -export async function fetchLandsOnProcessor(): Promise { +export type RootDestination = "processor" | "editor" | "signedOut"; + +/** + * The gates that need no backend lookup: soft-release flag off, no processor in + * this build, or the user pinned the editor in Settings. Returns the editor when + * one applies, else null (a lookup is needed). + */ +function shortCircuitToEditor(): string | null { + const pinnedEditor = + preferencesService.getPreference("loginLandingView") === "editor"; + return loginLandingMode() !== "dynamic" || + !isPortalAvailable() || + pinnedEditor + ? EDITOR_BASENAME + : null; +} + +/** + * Where a visitor to "/" should be sent, or null when there is no role to route + * on (signed out) and "/" should just render the app. See RootGate. + */ +export async function resolveRootTarget(): Promise { + const shortCircuit = shortCircuitToEditor(); + if (shortCircuit) return shortCircuit; + const destination = await fetchRootDestination(); + if (destination === "signedOut") return null; + return destination === "processor" ? PORTAL_BASENAME : EDITOR_BASENAME; +} + +/** + * Where a session that has just been established should land - the path a fresh + * login navigates to when it has no explicit destination of its own. + * + * Login resolves this itself rather than handing off to "/" so the app it just + * mounted is never torn down and remounted on the way through. "/" (RootGate) + * applies the same rules for people who simply arrive there. + */ +export async function resolveLandingPath(): Promise { + return (await resolveRootTarget()) ?? EDITOR_BASENAME; +} + +/** + * Where the current visitor should land from "/". One decision for all flavors: + * fetch the shared `/api/v1/auth/me`, then branch on whether `/api/v1/team/my` + * exists (SaaS) or 404s (self-hosted). An ambiguous lookup defaults to the + * editor (safe); an unauthenticated one reports `signedOut`. + */ +export async function fetchRootDestination(): Promise { let user: MeUser | undefined; try { const me = await apiClient.get<{ user?: MeUser }>("/api/v1/auth/me", { @@ -104,23 +122,25 @@ export async function fetchLandsOnProcessor(): Promise { }); user = me.data?.user; } catch { - return false; // not authenticated / unreachable → stay on the editor + return "signedOut"; // not authenticated / unreachable → let the app decide } - if (!user) return false; + if (!user) return "signedOut"; try { const teams = await apiClient.get("/api/v1/team/my", { suppressErrorToast: true, }); // SaaS: precise per-team data lets us exclude personal-team-only "leaders". - return isAdminRole(user.role) || leadsRealTeam(teams.data ?? []); + return isAdminRole(user.role) || leadsRealTeam(teams.data ?? []) + ? "processor" + : "editor"; } catch (e) { const status = (e as { response?: { status?: number } })?.response?.status; if (status === 404) { // Self-hosted: no /team/my. portalAccess (admin / grant / team owner) is // the clean signal there. - return user.portalAccess === true; + return user.portalAccess === true ? "processor" : "editor"; } - return false; // ambiguous lookup failure → stay on the editor + return "editor"; // ambiguous lookup failure → stay on the editor } } diff --git a/frontend/editor/src/prototypes/App.tsx b/frontend/editor/src/prototypes/App.tsx index 522d38f26e..4f36bd1de8 100644 --- a/frontend/editor/src/prototypes/App.tsx +++ b/frontend/editor/src/prototypes/App.tsx @@ -6,6 +6,7 @@ import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { PreferencesProvider } from "@app/contexts/PreferencesContext"; import { ThemeProvider } from "@app/components/shared/ThemeProvider"; import Landing from "@app/routes/Landing"; +import { RootGate } from "@app/routes/RootGate"; import Login from "@app/routes/Login"; import AuthCallback from "@app/routes/AuthCallback"; import InviteAccept from "@app/routes/InviteAccept"; @@ -52,29 +53,32 @@ export default function App() { } /> - {/* All other routes need AppProviders for backend integration */} + {/* All other routes need AppProviders for backend integration. + RootGate makes "/" route by role BEFORE any of it mounts. */} - - - } /> - {/* Self-hosted has no signup - accounts are created by an - admin. Old links land on login instead. */} - } - /> - } /> - } /> - } /> - {/* Main app routes - Landing handles auth logic */} - } /> - - - - + + + + + } /> + {/* Self-hosted has no signup - accounts are created by an + admin. Old links land on login instead. */} + } + /> + } /> + } /> + } /> + {/* Main app routes - Landing handles auth logic */} + } /> + + + + + } /> diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index aefd2854f3..67dce869fd 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -20,7 +20,7 @@ import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; -import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; +import { RootGate } from "@app/routes/RootGate"; const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); @@ -98,32 +98,35 @@ export default function App() { before the catch-all. */} {getAdminRouteExtensions()} - {/* Everything else needs the auth/backend providers. */} + {/* Everything else needs the auth/backend providers. RootGate makes "/" + route by role BEFORE any of it mounts, so a user bound for the + processor never boots the editor on the way. */} - - - - - } /> - } /> - } /> - } /> - } /> - {/* 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. */} - } /> - } /> - - - - + + + + + + } /> + } /> + } /> + } /> + } /> + {/* 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. */} + } /> + } /> + + + + + } /> diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx index a1d8af049c..8fbe925ff2 100644 --- a/frontend/editor/src/saas/routes/AuthCallback.tsx +++ b/frontend/editor/src/saas/routes/AuthCallback.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { resolveLandingPath } from "@app/utils/loginLanding"; import { supabase } from "@app/auth/supabase"; import { Button } from "@app/ui/Button"; import { withBasePath } from "@app/constants/app"; -import { markLoginLandingPending } from "@app/utils/loginLanding"; import { AuthShell } from "@app/auth/ui/AuthShell"; import ErrorMessage from "@app/auth/ui/ErrorMessage"; import { Spinner } from "@app/ui/Spinner"; @@ -131,14 +131,14 @@ export default function AuthCallback() { // Redirect to the intended destination. Reject protocol-relative // "//host" values (same guard as Login's `next`) so a crafted callback // URL can't bounce the user off-origin after sign-in. + // No explicit destination: land team leads on the processor and everyone + // else on the editor. const destination = - next.startsWith("/") && !next.startsWith("//") ? next : "/"; + next.startsWith("/") && !next.startsWith("//") + ? next + : await resolveLandingPath(); console.log("[Auth Callback Debug] Redirecting to:", destination); - // Fresh OAuth / magic-link login with no explicit destination: let the - // role-based landing redirect route team leads to the processor. - if (destination === "/") markLoginLandingPending(); - setTimeout(() => navigate(destination, { replace: true }), 1500); } catch (err) { console.error("[Auth Callback Debug] Unexpected error:", err); diff --git a/frontend/editor/src/saas/routes/Landing.tsx b/frontend/editor/src/saas/routes/Landing.tsx index a3e65d1e8f..59b72b5e35 100644 --- a/frontend/editor/src/saas/routes/Landing.tsx +++ b/frontend/editor/src/saas/routes/Landing.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { useAuth } from "@app/auth/UseSession"; import { useAutoAnonymousAuth } from "@app/hooks/useAutoAnonymousAuth"; -import { isToolRoute } from "@app/utils/pathUtils"; +import { isHomeRoute, isToolRoute } from "@app/utils/pathUtils"; import HomePage from "@app/pages/HomePage"; import Login from "@app/routes/Login"; import GuestUserBanner from "@app/components/auth/GuestUserBanner"; @@ -77,20 +77,35 @@ export default function Landing() { ); } + // Where to come back to after signing in. Carried in the URL (not just router + // state) so it survives a reload, and because `next` is what Login forwards + // through the OAuth round-trip. Landing on /editor signed out therefore + // returns to /editor after login rather than to the role router. + const returnTo = encodeURIComponent(location.pathname + location.search); + // If auto-authentication failed, navigate to login with error state if (autoAuthError && shouldTriggerAutoAuth) { return ( - + ); } // If we're at home route ("/"), show login directly (marketing/landing page) // Otherwise navigate to login (fixes URL mismatch for tool routes) - const isHome = location.pathname === "/" || location.pathname === ""; - if (isHome) { + if (location.pathname === "" || isHomeRoute(location.pathname)) { return ; } - // For non-home routes without auth, navigate to login (preserves from location) - return ; + // For non-home routes without auth, navigate to login (preserves where we came from) + return ( + + ); } diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index 13e74fc394..418616104e 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { resolveLandingPath } from "@app/utils/loginLanding"; import { supabase, signInAnonymously } from "@app/auth/supabase"; import { Button } from "@app/ui/Button"; import { useAuth } from "@app/auth/UseSession"; @@ -20,7 +21,6 @@ import ErrorMessage from "@app/auth/ui/ErrorMessage"; import EmailPasswordForm from "@app/routes/login/EmailPasswordForm"; import OAuthButtons from "@app/routes/login/OAuthButtons"; import LoggedInState from "@app/routes/login/LoggedInState"; -import { markLoginLandingPending } from "@app/utils/loginLanding"; import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; export default function Login() { @@ -165,10 +165,10 @@ export default function Login() { setError(error.message); } else if (data.user) { console.log("[Login] Email sign in successful"); - // Fresh login with no explicit destination: let the role-based landing - // redirect route team leads to the processor. User is redirected by the - // auth state change. - if (!nextPath) markLoginLandingPending(); + // No explicit destination: land team leads on the processor and everyone + // else on the editor. Resolved here rather than by bouncing through "/" + // so the app isn't torn down and remounted on the way. + if (!nextPath) navigate(await resolveLandingPath(), { replace: true }); } } catch (err) { console.error("[Login] Unexpected error]:", err); diff --git a/frontend/editor/src/saas/utils/pathUtils.ts b/frontend/editor/src/saas/utils/pathUtils.ts index 72abdf2226..58091d231e 100644 --- a/frontend/editor/src/saas/utils/pathUtils.ts +++ b/frontend/editor/src/saas/utils/pathUtils.ts @@ -35,7 +35,9 @@ export function isAuthRoute(pathname: string): boolean { } /** - * Check if pathname is home route + * Check if pathname is home route. Only "/" - the editor's own URL is a real + * destination, so a signed-out visit there bounces to /login carrying a return + * path, the same as any other non-home route. */ export function isHomeRoute(pathname: string): boolean { return normalizePath(pathname) === "/";