Editor url role routing (#7409)

# 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.
This commit is contained in:
Anthony Stirling
2026-08-12 10:09:18 +00:00
committed by GitHub
parent c2e8c3fa71
commit dc75d399bc
59 changed files with 731 additions and 615 deletions
+6
View File
@@ -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",
+8 -8
View File
@@ -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"
}
}
@@ -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",
+25 -24
View File
@@ -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;
@@ -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 ─────────────────────────────────────────────────
@@ -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<AppConfigModalProps> = ({
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);
}
@@ -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(
+3 -2
View File
@@ -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
}
+3 -2
View File
@@ -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) => {
@@ -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) {
@@ -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 = "/";
@@ -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 });
@@ -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 });
}
/**
@@ -54,7 +54,10 @@ const STUB_JWT = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdHViLXVzZXIifQ.signature";
export const test = base.extend<StubFixtures>({
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) => {
@@ -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(
@@ -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,
});
@@ -73,7 +73,7 @@ async function setUpAdminWithAudit(
],
}),
);
await page.goto("/");
await page.goto("/editor");
}
test.describe("Audit log UI", () => {
@@ -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,
@@ -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);
@@ -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);
});
@@ -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 });
@@ -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", () => {
@@ -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(
@@ -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(
@@ -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");
});
});
@@ -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
@@ -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))) {
@@ -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.
@@ -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 });
@@ -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", () => {
@@ -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();
+3 -2
View File
@@ -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);
}
/**
@@ -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;
@@ -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 = "/";
+10 -7
View File
@@ -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;
@@ -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;
};
+26 -23
View File
@@ -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. */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<LoginLandingRedirect />
<Routes>
<Route path="/login" element={<Login />} />
{/* Self-hosted has no signup - accounts are created by an
admin. Old links land on login instead. */}
<Route
path="/signup"
element={<Navigate to="/login" replace />}
/>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/share/:token" element={<ShareLinkPage />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
</AppLayout>
</AppProviders>
<RootGate>
<AppProviders>
<AppLayout>
<Routes>
<Route path="/login" element={<Login />} />
{/* Self-hosted has no signup - accounts are created by an
admin. Old links land on login instead. */}
<Route
path="/signup"
element={<Navigate to="/login" replace />}
/>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/share/:token" element={<ShareLinkPage />} />
{/* The editor and its tool routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
</AppLayout>
</AppProviders>
</RootGate>
}
/>
</Routes>
@@ -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 <div data-testid="pathname">{useLocation().pathname}</div>;
}
function renderAt(pathname = "/", strict = false) {
const tree = (
<MemoryRouter initialEntries={[pathname]}>
<LoginLandingRedirect />
<LocationProbe />
</MemoryRouter>
);
return render(strict ? <StrictMode>{tree}</StrictMode> : 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("/");
});
});
@@ -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 (
<div
style={{
position: "fixed",
inset: 0,
zIndex: Z_INDEX_SIGN_IN_MODAL,
background: "var(--c-surface)",
}}
>
<LoadingFallback />
</div>
);
}
return null;
}
export default LoginLandingRedirect;
@@ -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;
@@ -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]);
@@ -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();
});
@@ -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}`,
);
@@ -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 <HomePage />;
}
// 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 ? (
<Navigate to="/login" replace state={{ from: location }} />
<Navigate
to={`/login?from=${returnTo}`}
replace
state={{ from: location }}
/>
) : (
<HomePage />
);
@@ -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=<where they were>,
// 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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
};
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 });
});
});
@@ -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 <Navigate to="/" replace />;
return <Navigate to={EDITOR_BASENAME} replace />;
}
// Show logged in state if authenticated
@@ -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 <div data-testid="pathname">{useLocation().pathname}</div>;
}
/** Stands in for the whole app tree, so "did the app mount?" is observable. */
function TheApp() {
return <div data-testid="app">the app</div>;
}
// 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 = (
<MemoryRouter initialEntries={[pathname]}>
<Routes>
<Route
path="/processor/*"
element={<div data-testid="processor">the processor</div>}
/>
<Route
path="*"
element={
<RootGate>
<TheApp />
</RootGate>
}
/>
</Routes>
<LocationProbe />
</MemoryRouter>
);
return render(strict ? <StrictMode>{tree}</StrictMode> : 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();
});
});
@@ -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<Decision | null>(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 <LoadingFallback />;
if (decision.target === null) return <>{children}</>;
return <Navigate to={decision.target} replace />;
}
export default RootGate;
@@ -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",
@@ -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
@@ -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";
@@ -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>): 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");
});
});
@@ -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<boolean> {
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<string | null> {
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<string> {
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<RootDestination> {
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<boolean> {
});
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<LandingTeam[]>("/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
}
}
+24 -20
View File
@@ -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. */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<Routes>
<Route path="/login" element={<Login />} />
{/* Self-hosted has no signup - accounts are created by an
admin. Old links land on login instead. */}
<Route
path="/signup"
element={<Navigate to="/login" replace />}
/>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/share/:token" element={<ShareLinkPage />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
<RootGate>
<AppProviders>
<AppLayout>
<Routes>
<Route path="/login" element={<Login />} />
{/* Self-hosted has no signup - accounts are created by an
admin. Old links land on login instead. */}
<Route
path="/signup"
element={<Navigate to="/login" replace />}
/>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/share/:token" element={<ShareLinkPage />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
</RootGate>
}
/>
</Routes>
+27 -24
View File
@@ -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. */}
<Route
path="*"
element={
<AppProviders
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
>
<AppLayout>
<NonAuthBootstraps />
<LoginLandingRedirect />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/reset" element={<ResetPassword />} />
<Route path="/oauth/consent" element={<OAuthConsent />} />
{/* Shared-file links. Team invites are NOT routed here: on
SaaS they are accepted in-app via the Supabase team
invitation banner, not the Spring password-based
/invite/:token page used by the self-hosted build. */}
<Route path="/share/:token" element={<ShareLinkPage />} />
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
</AppProviders>
<RootGate>
<AppProviders
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
>
<AppLayout>
<NonAuthBootstraps />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/reset" element={<ResetPassword />} />
<Route path="/oauth/consent" element={<OAuthConsent />} />
{/* Shared-file links. Team invites are NOT routed here: on
SaaS they are accepted in-app via the Supabase team
invitation banner, not the Spring password-based
/invite/:token page used by the self-hosted build. */}
<Route path="/share/:token" element={<ShareLinkPage />} />
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
</AppProviders>
</RootGate>
}
/>
</Routes>
@@ -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);
+21 -6
View File
@@ -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 (
<Navigate to="/login" replace state={{ autoAuthError, from: location }} />
<Navigate
to={`/login?next=${returnTo}`}
replace
state={{ autoAuthError, from: location }}
/>
);
}
// 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 <Login />;
}
// For non-home routes without auth, navigate to login (preserves from location)
return <Navigate to="/login" replace state={{ from: location }} />;
// For non-home routes without auth, navigate to login (preserves where we came from)
return (
<Navigate
to={`/login?next=${returnTo}`}
replace
state={{ from: location }}
/>
);
}
+5 -5
View File
@@ -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);
+3 -1
View File
@@ -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) === "/";