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")}

- +