From 3fa0f30d43c08e7afb52d05fb62083a76d9dcd0e Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:39:37 +0100 Subject: [PATCH] =?UTF-8?q?Portal:=20prep=20for=20SaaS=20launch=20?= =?UTF-8?q?=E2=80=94=20hide=20unfinished=20sections,=20fix=20api=20client,?= =?UTF-8?q?=20docs=20link=20(#6921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this changes Getting the portal ready to show the world on SaaS. A few things bundled in here: **Developer docs tab** — now opens https://docs.stirlingpdf.com/ in a new tab instead of taking you to an empty page (we haven't built the in-app docs page yet). **Hid the bits that aren't finished yet — SaaS only:** - Took the Agent Builder button off the Sources page. - Removed the Components page. - Infrastructure: the tabs that aren't ready (Deployments, Security, Models, Storage) are greyed out as "coming soon". API keys and Audit stay live. Also dropped the "Manage editor deployment" button. - Removed the floating AI assistant blob. **Fixed the SaaS api client.** Before this, only the usage/billing page actually reached the backend — everything else (sources, users, policies, etc.) was going to the vite dev server with the wrong login, so it never worked. Now every portal call goes to the one SaaS backend using the Supabase login. Self-hosted is left exactly as it was — all the SaaS hides go through the saas override layer, so self-hosted still shows everything. ## Testing typecheck (all variants), full test suite, both builds, lint + format — all green. --- .../public/locales/en-US/translation.toml | 3 + .../src/portal-saas/api/localBackend.test.ts | 32 +++++++ .../src/portal-saas/api/localBackend.ts | 22 +++++ .../components/AssistantMount.test.tsx | 10 +++ .../portal-saas/components/AssistantMount.tsx | 7 ++ .../components/sidebarGroups.test.ts | 32 +++++++ .../portal-saas/components/sidebarGroups.tsx | 18 ++++ .../sources/AgentBuilderAction.test.tsx | 10 +++ .../components/sources/AgentBuilderAction.tsx | 7 ++ .../src/portal-saas/views/AgentBuilder.tsx | 10 +++ .../src/portal-saas/views/Components.tsx | 10 +++ .../portal-saas/views/Infrastructure.test.tsx | 44 ++++++++++ .../src/portal-saas/views/Infrastructure.tsx | 86 +++++++++++++++++++ frontend/editor/src/portal/api/http.ts | 44 +++++----- .../editor/src/portal/api/localBackend.ts | 33 +++++++ .../src/portal/components/AssistantMount.tsx | 15 ++++ .../src/portal/components/PortalChrome.tsx | 6 +- .../editor/src/portal/components/Sidebar.tsx | 48 +++-------- .../src/portal/components/sidebarGroups.tsx | 44 ++++++++++ .../components/sources/AgentBuilderAction.tsx | 22 +++++ frontend/editor/src/portal/views/Sources.tsx | 12 +-- 21 files changed, 445 insertions(+), 70 deletions(-) create mode 100644 frontend/editor/src/portal-saas/api/localBackend.test.ts create mode 100644 frontend/editor/src/portal-saas/api/localBackend.ts create mode 100644 frontend/editor/src/portal-saas/components/AssistantMount.test.tsx create mode 100644 frontend/editor/src/portal-saas/components/AssistantMount.tsx create mode 100644 frontend/editor/src/portal-saas/components/sidebarGroups.test.ts create mode 100644 frontend/editor/src/portal-saas/components/sidebarGroups.tsx create mode 100644 frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.test.tsx create mode 100644 frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.tsx create mode 100644 frontend/editor/src/portal-saas/views/AgentBuilder.tsx create mode 100644 frontend/editor/src/portal-saas/views/Components.tsx create mode 100644 frontend/editor/src/portal-saas/views/Infrastructure.test.tsx create mode 100644 frontend/editor/src/portal-saas/views/Infrastructure.tsx create mode 100644 frontend/editor/src/portal/api/localBackend.ts create mode 100644 frontend/editor/src/portal/components/AssistantMount.tsx create mode 100644 frontend/editor/src/portal/components/sidebarGroups.tsx create mode 100644 frontend/editor/src/portal/components/sources/AgentBuilderAction.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 6c301dac25..7e0ca0116b 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6070,6 +6070,9 @@ dismiss = "Dismiss overlay" enforcingTitle = "Enforcing policy..." viewAnyway = "View file (policy still enforcing)" +[portal] +comingSoon = "Coming soon" + [portal.accountLink.card] billingNote = "Unattended processing bills against your org wallet." eyebrow = "Account link" diff --git a/frontend/editor/src/portal-saas/api/localBackend.test.ts b/frontend/editor/src/portal-saas/api/localBackend.test.ts new file mode 100644 index 0000000000..0c7fa21058 --- /dev/null +++ b/frontend/editor/src/portal-saas/api/localBackend.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const getPortalSaasToken = vi.fn(); +vi.mock("@portal/auth/portalSaasSession", () => ({ + getPortalSaasToken: () => getPortalSaasToken(), +})); +vi.mock("@portal/api/saasApiBase", () => ({ + saasApiBase: () => "https://saas.example", +})); + +// Resolves to the SaaS override (src/portal-saas) via the @portal cascade. +import { localBaseUrl, localAuthHeader } from "@portal/api/localBackend"; + +describe("localBackend (SaaS) — apiClient.local IS the SaaS backend", () => { + afterEach(() => getPortalSaasToken.mockReset()); + + it("targets the SaaS backend base, not same-origin", () => { + expect(localBaseUrl()).toBe("https://saas.example"); + }); + + it("authenticates with the Supabase JWT", async () => { + getPortalSaasToken.mockResolvedValue("supabase-jwt"); + expect(await localAuthHeader()).toEqual({ + Authorization: "Bearer supabase-jwt", + }); + }); + + it("sends no auth header when there is no session", async () => { + getPortalSaasToken.mockResolvedValue(null); + expect(await localAuthHeader()).toEqual({}); + }); +}); diff --git a/frontend/editor/src/portal-saas/api/localBackend.ts b/frontend/editor/src/portal-saas/api/localBackend.ts new file mode 100644 index 0000000000..35671b519a --- /dev/null +++ b/frontend/editor/src/portal-saas/api/localBackend.ts @@ -0,0 +1,22 @@ +import { saasApiBase } from "@portal/api/saasApiBase"; +import { getPortalSaasToken } from "@portal/auth/portalSaasSession"; + +/** + * SaaS build: there is no separate local instance — {@code apiClient.local} IS the + * SaaS backend. Route it at the one backend (VITE_API_BASE_URL, via saasApiBase) + * with the admin's Supabase JWT, identical to {@code apiClient.saas}. So "local" + * and "saas" calls both reach the SaaS backend authenticated; there is no + * same-origin + Spring path on SaaS. + */ +export function localBaseUrl(): string { + return saasApiBase(); +} + +export async function localAuthHeader(): Promise> { + const token = await getPortalSaasToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export function onLocalUnauthorized(): void { + // No Spring token on SaaS; PortalAuthBoundary handles Supabase session expiry. +} diff --git a/frontend/editor/src/portal-saas/components/AssistantMount.test.tsx b/frontend/editor/src/portal-saas/components/AssistantMount.test.tsx new file mode 100644 index 0000000000..331c23c219 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/AssistantMount.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { AssistantMount } from "@portal/components/AssistantMount"; + +describe("AssistantMount (SaaS)", () => { + it("renders nothing — the AI assistant blob is hidden pre-release", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/AssistantMount.tsx b/frontend/editor/src/portal-saas/components/AssistantMount.tsx new file mode 100644 index 0000000000..87fe740d1d --- /dev/null +++ b/frontend/editor/src/portal-saas/components/AssistantMount.tsx @@ -0,0 +1,7 @@ +/** + * SaaS pre-release: the AI assistant isn't shipped yet, so the floating blob + + * panel are hidden. + */ +export function AssistantMount() { + return null; +} diff --git a/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts b/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts new file mode 100644 index 0000000000..892d1b0514 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +// Resolves to the SaaS override (src/portal-saas) via the @portal cascade. +import { + GROUP_PRIMARY, + GROUP_OPERATIONAL, + GROUP_PLATFORM, +} from "@portal/components/sidebarGroups"; + +describe("sidebarGroups (SaaS)", () => { + it("drops Components from the operational nav", () => { + expect(GROUP_OPERATIONAL.map((e) => e.id)).not.toContain("components"); + }); + + it("inherits the other operational items from base", () => { + expect(GROUP_OPERATIONAL.map((e) => e.id)).toEqual([ + "users", + "sources", + "policies", + "pipelines", + "documents", + ]); + }); + + it("inherits the primary + platform groups unchanged", () => { + expect(GROUP_PRIMARY.map((e) => e.id)).toEqual(["home"]); + expect(GROUP_PLATFORM.map((e) => e.id)).toEqual([ + "infrastructure", + "usage", + "docs", + ]); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/sidebarGroups.tsx b/frontend/editor/src/portal-saas/components/sidebarGroups.tsx new file mode 100644 index 0000000000..1503d6a1e3 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/sidebarGroups.tsx @@ -0,0 +1,18 @@ +import { + GROUP_PRIMARY, + GROUP_OPERATIONAL as BASE_OPERATIONAL, + GROUP_PLATFORM, + type NavEntry, +} from "@portal-proprietary/components/sidebarGroups"; + +export { GROUP_PRIMARY, GROUP_PLATFORM }; +export type { NavEntry }; + +/** + * SaaS pre-release: the Components section isn't shipped there yet, so drop it + * from the operational nav. Everything else is inherited from the base groups, so + * new nav items appear in SaaS automatically — only Components is removed here. + */ +export const GROUP_OPERATIONAL: NavEntry[] = BASE_OPERATIONAL.filter( + (entry) => entry.id !== "components", +); diff --git a/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.test.tsx b/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.test.tsx new file mode 100644 index 0000000000..2dcafa1dcf --- /dev/null +++ b/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction"; + +describe("AgentBuilderAction (SaaS)", () => { + it("renders nothing — Agent Builder is hidden pre-release", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.tsx b/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.tsx new file mode 100644 index 0000000000..0576cf45e9 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.tsx @@ -0,0 +1,7 @@ +/** + * SaaS pre-release: Agent Builder isn't shipped yet, so its Sources-header entry + * point is hidden. + */ +export function AgentBuilderAction() { + return null; +} diff --git a/frontend/editor/src/portal-saas/views/AgentBuilder.tsx b/frontend/editor/src/portal-saas/views/AgentBuilder.tsx new file mode 100644 index 0000000000..e6d4dbb15b --- /dev/null +++ b/frontend/editor/src/portal-saas/views/AgentBuilder.tsx @@ -0,0 +1,10 @@ +import { Navigate } from "react-router-dom"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; + +/** + * SaaS pre-release: Agent Builder isn't shipped yet and its Sources entry point is + * hidden, so redirect any /agent-builder deep link back to Home. + */ +export function AgentBuilder() { + return ; +} diff --git a/frontend/editor/src/portal-saas/views/Components.tsx b/frontend/editor/src/portal-saas/views/Components.tsx new file mode 100644 index 0000000000..4eb700e4cf --- /dev/null +++ b/frontend/editor/src/portal-saas/views/Components.tsx @@ -0,0 +1,10 @@ +import { Navigate } from "react-router-dom"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; + +/** + * SaaS pre-release: the Components page isn't shipped yet and its nav item is + * hidden, so redirect any /components deep link back to Home. + */ +export function Components() { + return ; +} diff --git a/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx b/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx new file mode 100644 index 0000000000..f884d7e6f4 --- /dev/null +++ b/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, def?: string) => def ?? key, + i18n: { changeLanguage: vi.fn() }, + }), +})); +// Stub the live tab panels so the test doesn't pull their data dependencies. +vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({ + ApiKeysTab: () =>
, +})); +vi.mock("@portal/components/infrastructure/AuditTab", () => ({ + AuditTab: () =>
, +})); + +import { Infrastructure } from "@portal/views/Infrastructure"; + +describe("Infrastructure (SaaS)", () => { + it("defaults to the live API keys tab and drops the manage-editor button", () => { + render(); + expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument(); + expect( + screen.queryByText("portal.infrastructure.manageEditorDeployment"), + ).not.toBeInTheDocument(); + }); + + it("renders the not-yet-shipped tabs as disabled 'coming soon'", () => { + render(); + for (const tab of ["deployments", "security", "models", "storage"]) { + const btn = screen.getByRole("button", { + name: new RegExp(`portal.infrastructure.tabs.${tab}`), + }); + expect(btn).toBeDisabled(); + } + // The live tabs are not disabled. + expect( + screen.getByRole("button", { + name: /portal.infrastructure.tabs.apiKeys/, + }), + ).toBeEnabled(); + }); +}); diff --git a/frontend/editor/src/portal-saas/views/Infrastructure.tsx b/frontend/editor/src/portal-saas/views/Infrastructure.tsx new file mode 100644 index 0000000000..f52453e716 --- /dev/null +++ b/frontend/editor/src/portal-saas/views/Infrastructure.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Tabs, type TabItem } from "@app/ui"; +import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab"; +import { AuditTab } from "@portal/components/infrastructure/AuditTab"; +import "@portal/views/Infrastructure.css"; + +// SaaS pre-release: only API keys + Audit are shipped. Deployments, Security, +// Models and Storage are shown as disabled "coming soon" tabs (greyed, to the +// right of the live ones), and the self-hosted-only "Manage editor deployment" +// header button is dropped. Selection is never one of the coming-soon keys — the +// Tabs primitive renders them as native-disabled buttons, so onChange can't fire. +type InfraTab = + | "api-keys" + | "audit" + | "deployments" + | "security" + | "models" + | "storage"; + +export function Infrastructure() { + const { t } = useTranslation(); + const [tab, setTab] = useState("api-keys"); + + const comingSoon = (labelKey: string) => ( + <> + {t(labelKey)}{" "} + + · {t("portal.comingSoon", "Coming soon")} + + + ); + + const tabs: TabItem[] = [ + { key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") }, + { key: "audit", label: t("portal.infrastructure.tabs.audit") }, + { + key: "deployments", + label: comingSoon("portal.infrastructure.tabs.deployments"), + disabled: true, + }, + { + key: "security", + label: comingSoon("portal.infrastructure.tabs.security"), + disabled: true, + }, + { + key: "models", + label: comingSoon("portal.infrastructure.tabs.models"), + disabled: true, + }, + { + key: "storage", + label: comingSoon("portal.infrastructure.tabs.storage"), + disabled: true, + }, + ]; + + return ( +
+
+
+

+ {t("portal.infrastructure.title")} +

+

+ {t("portal.infrastructure.subtitle")} +

+
+
+ + + items={tabs} + activeKey={tab} + onChange={setTab} + variant="underline" + ariaLabel={t("portal.infrastructure.sectionsAriaLabel")} + /> + +
+ {tab === "api-keys" && } + {tab === "audit" && } +
+
+ ); +} diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts index 17bc55a530..ac48cccf08 100644 --- a/frontend/editor/src/portal/api/http.ts +++ b/frontend/editor/src/portal/api/http.ts @@ -3,9 +3,11 @@ * * ## Domains * - * apiClient.local Same-origin (vite proxy → this instance's local - * Stirling backend on :8080). Spring admin bearer - * (`stirling_jwt` from @app/auth) auto-attached. + * apiClient.local This instance's backend, via the localBackend seam. + * Self-hosted: same-origin (vite proxy → local Stirling + * backend on :8080), Spring admin bearer. SaaS: there is + * no separate local instance, so it targets the one SaaS + * backend with the Supabase JWT (same as .saas). * USE FOR: actions on this instance — * /api/v1/account-link/{status,link,unlink}, etc. * @@ -39,9 +41,13 @@ * entitlement calls. It never enters the portal — the browser is the human * admin and uses the Supabase JWT for SaaS reads. Don't add it here. */ -import { clearStoredToken, getStoredToken } from "@app/auth"; import { getPortalSaasToken } from "@portal/auth/portalSaasSession"; import { saasApiBase } from "@portal/api/saasApiBase"; +import { + localAuthHeader, + localBaseUrl, + onLocalUnauthorized, +} from "@portal/api/localBackend"; /** * SaaS base URL via the flavor seam: self-hosted reads VITE_SAAS_API_URL (a @@ -134,53 +140,49 @@ async function unwrap(res: Response): Promise { } // ──────────────────────────────────────────────────────────────────────────── -// local — same-origin Stirling backend, Spring admin bearer +// local — this instance's backend, via the localBackend seam (base URL + auth). +// Self-hosted: same-origin + Spring bearer. SaaS: the SaaS backend + Supabase JWT. // ──────────────────────────────────────────────────────────────────────────── -function localAuthHeader(): Record { - const token = getStoredToken(); - return token ? { Authorization: `Bearer ${token}` } : {}; -} - async function localJson( path: string, options: HttpRequestOptions = {}, ): Promise { - const res = await fetch(path, { + const res = await fetch(`${localBaseUrl()}${path}`, { method: options.method ?? "GET", headers: { Accept: "application/json", ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), - ...localAuthHeader(), + ...(await localAuthHeader()), ...options.headers, }, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, signal: options.signal, }); if (res.status === 401) { - // Stale or invalid JWT — clear it so the auth provider re-initialises and - // shows the login screen rather than leaving the user stuck with a banner. - clearStoredToken(); - window.dispatchEvent(new CustomEvent("jwt-available")); + // Stale/invalid credential — let the flavor decide (self-hosted clears the + // Spring token to re-show login; SaaS lets the auth boundary handle it). + onLocalUnauthorized(); } return unwrap(res); } -/** Same-origin GET returning a binary Blob (e.g. a CSV/JSON export download). */ +/** GET returning a binary Blob (e.g. a CSV/JSON export download), via the + * localBackend seam — same base + auth as localJson (SaaS backend + Supabase JWT + * on SaaS, same-origin + Spring bearer self-hosted). */ async function localBlob( path: string, options: HttpRequestOptions = {}, ): Promise { - const res = await fetch(path, { + const res = await fetch(`${localBaseUrl()}${path}`, { method: options.method ?? "GET", - headers: { ...localAuthHeader(), ...options.headers }, + headers: { ...(await localAuthHeader()), ...options.headers }, signal: options.signal, }); if (res.status === 401) { - clearStoredToken(); - window.dispatchEvent(new CustomEvent("jwt-available")); + onLocalUnauthorized(); } if (!res.ok) throw new HttpError(res.status, res.statusText, null); return res.blob(); diff --git a/frontend/editor/src/portal/api/localBackend.ts b/frontend/editor/src/portal/api/localBackend.ts new file mode 100644 index 0000000000..3716c2562c --- /dev/null +++ b/frontend/editor/src/portal/api/localBackend.ts @@ -0,0 +1,33 @@ +import { clearStoredToken, getStoredToken } from "@app/auth"; + +/** + * Transport config for {@code apiClient.local} — the flavor seam behind "this + * instance's backend". + * + * Self-hosted (this base): same-origin (the local Stirling backend, vite-proxied + * to :8080 in dev), authenticated with the Spring admin bearer. + * + * The SaaS build shadows this file: there is no separate local instance, so + * {@code apiClient.local} targets the one SaaS backend (VITE_API_BASE_URL, via + * saasApiBase) with the admin's Supabase JWT — the same transport as + * {@code apiClient.saas}. There is no same-origin + Spring path in SaaS. + */ +export function localBaseUrl(): string { + return ""; +} + +/** Auth header for apiClient.local — the Spring admin bearer, when present. */ +export async function localAuthHeader(): Promise> { + const token = getStoredToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +/** + * Called on a 401 from apiClient.local. Self-hosted: drop the stale Spring token + * so the auth provider re-initialises and shows the login screen rather than + * leaving the user stuck with a banner. + */ +export function onLocalUnauthorized(): void { + clearStoredToken(); + window.dispatchEvent(new CustomEvent("jwt-available")); +} diff --git a/frontend/editor/src/portal/components/AssistantMount.tsx b/frontend/editor/src/portal/components/AssistantMount.tsx new file mode 100644 index 0000000000..97de389c95 --- /dev/null +++ b/frontend/editor/src/portal/components/AssistantMount.tsx @@ -0,0 +1,15 @@ +import { AssistantButton } from "@portal/components/AssistantButton"; +import { AssistantPanel } from "@portal/components/AssistantPanel"; + +/** + * Mounts the floating AI assistant (blob button + slide-in panel). A flavor seam: + * the SaaS build shadows this with a no-op so the assistant is hidden pre-release. + */ +export function AssistantMount() { + return ( + <> + + + + ); +} diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx index 2066e740d8..f5b524145d 100644 --- a/frontend/editor/src/portal/components/PortalChrome.tsx +++ b/frontend/editor/src/portal/components/PortalChrome.tsx @@ -4,8 +4,7 @@ import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; import { ErrorBoundary } from "@portal/components/ErrorBoundary"; import { useUI } from "@portal/contexts/UIContext"; import { AppShell } from "@portal/components/AppShell"; -import { AssistantButton } from "@portal/components/AssistantButton"; -import { AssistantPanel } from "@portal/components/AssistantPanel"; +import { AssistantMount } from "@portal/components/AssistantMount"; import { SearchModal } from "@portal/components/SearchModal"; import { SettingsModal } from "@portal/components/SettingsModal"; import { ViewRouter } from "@portal/ViewRouter"; @@ -79,8 +78,7 @@ export function PortalChrome() { - - + diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index fed5d7f68e..fdb5a61b03 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -12,43 +12,15 @@ import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; +import { SettingsIcon } from "@portal/components/icons"; import { - HomeIcon, - UsersIcon, - SourcesIcon, - PoliciesIcon, - PipelinesIcon, - DocumentsIcon, - ComponentsIcon, - InfrastructureIcon, - UsageIcon, - DocsIcon, - SettingsIcon, -} from "@portal/components/icons"; + GROUP_PRIMARY, + GROUP_OPERATIONAL, + GROUP_PLATFORM, + type NavEntry, +} from "@portal/components/sidebarGroups"; import "@portal/components/Sidebar.css"; -interface NavEntry { - id: ViewId; - icon: React.ReactNode; -} - -const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: }]; - -const GROUP_OPERATIONAL: NavEntry[] = [ - { id: "users", icon: }, - { id: "sources", icon: }, - { id: "policies", icon: }, - { id: "pipelines", icon: }, - { id: "documents", icon: }, - { id: "components", icon: }, -]; - -const GROUP_PLATFORM: NavEntry[] = [ - { id: "infrastructure", icon: }, - { id: "usage", icon: }, - { id: "docs", icon: }, -]; - function UsageFooter() { const { tier } = useTier(); const { t } = useTranslation(); @@ -136,7 +108,13 @@ export function Sidebar() { label={t(`portal.nav.${entry.id}`)} icon={entry.icon} isActive={activeView === entry.id} - onClick={(id) => setActiveView(id as ViewId)} + onClick={(id) => { + if (entry.externalUrl) { + window.open(entry.externalUrl, "_blank", "noopener,noreferrer"); + } else { + setActiveView(id as ViewId); + } + }} /> )); } diff --git a/frontend/editor/src/portal/components/sidebarGroups.tsx b/frontend/editor/src/portal/components/sidebarGroups.tsx new file mode 100644 index 0000000000..71b7539412 --- /dev/null +++ b/frontend/editor/src/portal/components/sidebarGroups.tsx @@ -0,0 +1,44 @@ +import { type ReactNode } from "react"; +import { type ViewId } from "@portal/contexts/ViewContext"; +import { + HomeIcon, + UsersIcon, + SourcesIcon, + PoliciesIcon, + PipelinesIcon, + DocumentsIcon, + ComponentsIcon, + InfrastructureIcon, + UsageIcon, + DocsIcon, +} from "@portal/components/icons"; + +export interface NavEntry { + id: ViewId; + icon: ReactNode; + /** When set, the tab opens this URL in a new tab instead of navigating in-app. */ + externalUrl?: string; +} + +// Developer docs has no built-in portal page yet, so the tab opens the hosted docs +// site in a new tab rather than routing to an empty page. +const DEVELOPER_DOCS_URL = "https://docs.stirlingpdf.com/"; + +// Sidebar nav groups. This is a flavor seam: the SaaS build shadows this file to +// drop sections not yet shipped there (see src/portal-saas/components/sidebarGroups). +export const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: }]; + +export const GROUP_OPERATIONAL: NavEntry[] = [ + { id: "users", icon: }, + { id: "sources", icon: }, + { id: "policies", icon: }, + { id: "pipelines", icon: }, + { id: "documents", icon: }, + { id: "components", icon: }, +]; + +export const GROUP_PLATFORM: NavEntry[] = [ + { id: "infrastructure", icon: }, + { id: "usage", icon: }, + { id: "docs", icon: , externalUrl: DEVELOPER_DOCS_URL }, +]; diff --git a/frontend/editor/src/portal/components/sources/AgentBuilderAction.tsx b/frontend/editor/src/portal/components/sources/AgentBuilderAction.tsx new file mode 100644 index 0000000000..5e3885e5fc --- /dev/null +++ b/frontend/editor/src/portal/components/sources/AgentBuilderAction.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import { useView } from "@portal/contexts/ViewContext"; +import { AgentBuilderIcon } from "@portal/components/icons"; + +/** + * The "Agent Builder" action in the Sources header. A flavor seam: the SaaS build + * shadows this with a no-op (Agent Builder isn't shipped there yet). + */ +export function AgentBuilderAction() { + const { t } = useTranslation(); + const { setActiveView } = useView(); + return ( + + ); +} diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index a7dd47136a..56ec5b4459 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui"; -import { useView } from "@portal/contexts/ViewContext"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; import { errorMessage } from "@portal/api/http"; import { @@ -15,7 +14,7 @@ import { type SourcesResponse, type SourceView, } from "@portal/api/sources"; -import { AgentBuilderIcon } from "@portal/components/icons"; +import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard"; @@ -24,7 +23,6 @@ import "@portal/views/Sources.css"; export function Sources() { const { t } = useTranslation(); - const { setActiveView } = useView(); const [searchParams, setSearchParams] = useSearchParams(); // Refetch after every mutation by bumping this counter, so the table reflects // the in-memory store the handlers maintain (mirrors the Policies view). @@ -139,13 +137,7 @@ export function Sources() {

{t("portal.sources.subtitle")}

- +