mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = "/";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) === "/";
|
||||
|
||||
Reference in New Issue
Block a user