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:
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 />}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user