Portal: move the admin route from /portal to /processor (#6933)

## What this changes

Moves the admin portal's browser route from **`/portal`** to
**`/processor`**, to match the "Processor" product name (the in-app app
switcher already says "processor").

- `PORTAL_BASENAME` `"/portal"` → `"/processor"` — the single source of
truth for all portal paths on the frontend, so every `toPortalPath(...)`
link and redirect follows automatically.
- `adminRouteExtensions` now mounts at `` `${PORTAL_BASENAME}/*` ``
instead of a hardcoded `"/portal/*"`, so the route can't drift from the
constant.
- **Backend:** `RequestUriUtils.isStaticResource` now treats
`/processor` (not `/portal`) as the SPA shell, so a direct navigation or
hard refresh to `/processor` serves the app instead of 404ing. (This is
the only backend URL reference — there's no Spring Security matcher for
it.)
- Updated the two portal route tests and the doc comments that named the
old path.

**Not changed:** the `@portal/*` import alias — that's the code layer /
flavor-layering path, not the URL. Renaming it would be a much larger,
unrelated churn.

The portal isn't publicly launched yet, so there are no existing links
to preserve — no redirect from the old path is included.

## Testing
- `task frontend:typecheck:all`, full test suite (1,211), lint, format —
green
- `./gradlew :common:test --tests "*RequestUriUtilsTest"` — green
This commit is contained in:
ConnorYoh
2026-07-09 12:01:48 +00:00
committed by GitHub
parent d3638d786d
commit 119eb1f5ad
10 changed files with 48 additions and 34 deletions
@@ -57,12 +57,13 @@ public class RequestUriUtils {
return true;
}
// Admin portal SPA shell. Served publicly like the editor root so a direct
// nav / refresh to /portal loads the app (the JWT lives in localStorage, not
// a cookie, so the server can't authenticate the navigation itself). The
// Admin portal SPA shell (mounted at /processor — must match the frontend
// PORTAL_BASENAME). Served publicly like the editor root so a direct nav /
// refresh to /processor loads the app (the JWT lives in localStorage, not a
// cookie, so the server can't authenticate the navigation itself). The
// portal gates access via its own auth gate + RequirePortalAccess, and its
// data APIs stay protected, so serving the shell pre-auth is safe.
if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) {
if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) {
return true;
}
@@ -75,10 +75,10 @@ class RequestUriUtilsTest {
@Test
void testIsStaticResource_portalShell() {
// The admin portal SPA shell is served pre-auth so it's directly navigable.
assertTrue(RequestUriUtils.isStaticResource("/portal"));
assertTrue(RequestUriUtils.isStaticResource("/portal/users"));
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal"));
// The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable.
assertTrue(RequestUriUtils.isStaticResource("/processor"));
assertTrue(RequestUriUtils.isStaticResource("/processor/users"));
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/processor"));
}
// --- isFrontendRoute tests ---
@@ -4,4 +4,4 @@
* mount point without importing portal code — build flavors that ship no
* portal (core, desktop, prototypes) must never resolve @portal.
*/
export const PORTAL_BASENAME = "/portal";
export const PORTAL_BASENAME = "/processor";
+3 -3
View File
@@ -17,11 +17,11 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) {
}
/**
* The portal, mounted as a route-set under /portal/* inside the editor app (via
* the admin-route seam). It supplies its own providers and its own i18next
* The portal, mounted as a route-set under /processor/* inside the editor app
* (via the admin-route seam). It supplies its own providers and its own i18next
* instance (the `portal` namespace), but NOT a router — the editor's
* <BrowserRouter> is the one and only router; the portal's routes are relative
* to the /portal mount (see ViewRouter).
* to the /processor mount (see ViewRouter).
*
* The provider stack itself is a per-flavor seam (see {@link PortalProviders}):
* self-hosted mounts the account-link layer, SaaS does not.
+1 -1
View File
@@ -15,7 +15,7 @@ import { DeveloperDocs } from "@portal/views/DeveloperDocs";
import { Procurement } from "@portal/views/Procurement";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
// The portal mounts as a route-set under /portal/* in the editor app, so these
// The portal mounts as a route-set under /processor/* in the editor app, so these
// child routes are relative to that base: strip the leading slash from the
// logical VIEW_PATHS, and home is the index route. Redirects use toPortalPath
// so they resolve to the portal, not the editor root.
+2 -2
View File
@@ -3,8 +3,8 @@
* bouncing non-admins out).
*
* Sourced from VITE_EDITOR_URL so it's configurable per deploy rather than
* hardcoded, falling back to "/" (the editor serves the portal at /portal on
* the same origin, so the root is the editor). For dev cross-app navigation to
* hardcoded, falling back to "/" (the editor serves the portal at /processor
* on the same origin, so the root is the editor). For dev cross-app navigation to
* a separately-running editor, set VITE_EDITOR_URL in editor/.env.local.
*/
export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL || "/";
@@ -61,7 +61,7 @@ export const VIEW_PATHS: Record<ViewId, string> = {
*/
export { PORTAL_BASENAME };
/** Logical view path -> full app path (e.g. "/users" -> "/portal/users"). */
/** Logical view path -> full app path (e.g. "/users" -> "/processor/users"). */
export function toPortalPath(viewPath: string): string {
return `${PORTAL_BASENAME}${viewPath === "/" ? "" : viewPath}`;
}
@@ -104,9 +104,12 @@ function renderBuilder(initial: string) {
return render(
<MemoryRouter initialEntries={[initial]}>
<Routes>
<Route path="/portal/pipelines/new" element={<PipelineBuilder />} />
<Route path="/portal/pipelines/:id" element={<PipelineBuilder />} />
<Route path="/portal/pipelines" element={<div>pipelines list</div>} />
<Route path="/processor/pipelines/new" element={<PipelineBuilder />} />
<Route path="/processor/pipelines/:id" element={<PipelineBuilder />} />
<Route
path="/processor/pipelines"
element={<div>pipelines list</div>}
/>
</Routes>
</MemoryRouter>,
);
@@ -131,7 +134,7 @@ describe("PipelineBuilder", () => {
});
it("builds a new pipeline: name it, add a tool, and save", async () => {
renderBuilder("/portal/pipelines/new");
renderBuilder("/processor/pipelines/new");
// The name field is the only textbox before the picker opens.
fireEvent.change(await screen.findByRole("textbox"), {
@@ -157,7 +160,7 @@ describe("PipelineBuilder", () => {
});
it("runs an existing pipeline and reports success", async () => {
renderBuilder("/portal/pipelines/plc-1");
renderBuilder("/processor/pipelines/plc-1");
fireEvent.click(await screen.findByText("portal.pipelines.detail.run"));
@@ -168,7 +171,7 @@ describe("PipelineBuilder", () => {
});
it("blocks saving a step that needs an uploaded file", async () => {
renderBuilder("/portal/pipelines/new");
renderBuilder("/processor/pipelines/new");
fireEvent.change(await screen.findByRole("textbox"), {
target: { value: "Watermarked" },
@@ -187,7 +190,7 @@ describe("PipelineBuilder", () => {
});
it("deletes an existing pipeline after confirmation", async () => {
renderBuilder("/portal/pipelines/plc-1");
renderBuilder("/processor/pipelines/plc-1");
fireEvent.click(await screen.findByText("portal.pipelines.detail.delete"));
fireEvent.click(await screen.findByText("portal.pipelines.delete.confirm"));
@@ -197,7 +200,7 @@ describe("PipelineBuilder", () => {
});
it("prompts to save or discard when leaving with unsaved edits", async () => {
renderBuilder("/portal/pipelines/new");
renderBuilder("/processor/pipelines/new");
fireEvent.change(await screen.findByRole("textbox"), {
target: { value: "Draft" },
@@ -212,7 +215,7 @@ describe("PipelineBuilder", () => {
});
it("leaves immediately when there are no unsaved edits", async () => {
renderBuilder("/portal/pipelines/new");
renderBuilder("/processor/pipelines/new");
await screen.findByRole("textbox");
fireEvent.click(screen.getByText("portal.pipelines.composer.cancel"));
@@ -48,14 +48,17 @@ const RESPONSE: PipelinesOverviewResponse = {
],
};
function renderView(initial = "/portal/pipelines") {
function renderView(initial = "/processor/pipelines") {
return render(
<MemoryRouter initialEntries={[initial]}>
<Routes>
<Route path="/portal/pipelines" element={<Pipelines />} />
<Route path="/portal/pipelines/new" element={<div>builder new</div>} />
<Route path="/processor/pipelines" element={<Pipelines />} />
<Route
path="/portal/pipelines/:id"
path="/processor/pipelines/new"
element={<div>builder new</div>}
/>
<Route
path="/processor/pipelines/:id"
element={<div>pipeline page</div>}
/>
</Routes>
@@ -1,6 +1,7 @@
import { lazy } from "react";
import type { ReactElement } from "react";
import { Route } from "react-router-dom";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
// The portal ships as a lazy chunk of the editor. It's included in dev (so it's
// always available to work on) and in production builds made with
@@ -24,12 +25,18 @@ const PortalApp = includePortal
: null;
/**
* The portal mounts as an admin-only route-set at /portal/*. Access is gated
* inside PortalApp (its own AuthProvider + AuthGate, plus server enforcement),
* so this just wires the lazy route into the editor's router when the portal is
* included in this build.
* The portal mounts as an admin-only route-set at PORTAL_BASENAME (/processor/*).
* Access is gated inside PortalApp (its own AuthProvider + AuthGate, plus server
* enforcement), so this just wires the lazy route into the editor's router when
* the portal is included in this build.
*/
export function getAdminRouteExtensions(): ReactElement[] {
if (!PortalApp) return [];
return [<Route key="portal" path="/portal/*" element={<PortalApp />} />];
return [
<Route
key="portal"
path={`${PORTAL_BASENAME}/*`}
element={<PortalApp />}
/>,
];
}