Fix Documents tab in Processor (#7569)

# Description of Changes
The Documents tab in the Processor is supposed to be available to all
Processor users, but because the API is built on top of the Audit data,
which is only for enterprise users, the API call always fails with 403.
This means that it never fills the query cache, so every time you go
back to the tab it has to reload all the data for a couple of seconds
(and will fail again). This fixes the API so that it's available to any
Processor user instead of just enterprise users. Also, the documents
data was only being written to the log on an enterprise license, so I've
changed it so that data is always tracked in the audit log because
otherwise the Documents tab would still be useless to non-enterprise
users.

The Audit Log tab was also available to all Processor users, but would
have the same issue where the table would never load because the API
would 403 as well. I've just made the Audit Log tab disabled for
non-enterprise users now. We might want to do something to signpost it a
bit more that it's an enterprise-specific feature, but it's better than
nothing for now.
This commit is contained in:
James Brunton
2026-08-24 15:06:22 +00:00
committed by GitHub
parent e50c3de0a9
commit 158187ac46
18 changed files with 410 additions and 60 deletions
+10 -1
View File
@@ -5,7 +5,16 @@ import { QueryClient, type DefaultOptions } from "@tanstack/react-query";
export const baseQueryOptions: DefaultOptions["queries"] = {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: 1,
// Retry once on transient failures, but never on a 4xx: an auth/forbidden/not-found
// response won't change on a second identical request, so retrying just doubles the
// wait (e.g. a 403 firing twice) before the UI settles.
retry: (failureCount, error) => {
const status = (error as { status?: number } | null)?.status;
if (typeof status === "number" && status >= 400 && status < 500) {
return false;
}
return failureCount < 1;
},
networkMode: "always",
refetchOnWindowFocus: false,
};
@@ -0,0 +1,13 @@
// SaaS enterprise flag: derived from the plan tier (wallet-backed), not a local
// license bean. Shadows the self-hosted app-config version so Enterprise-only
// surfaces (e.g. Infrastructure > Audit) unlock for enterprise-plan tenants.
import { useTier } from "@portal/contexts/TierContext";
import type { EnterpriseState } from "@portal-proprietary/hooks/useEnterpriseEnabled";
export type { EnterpriseState };
export function useEnterpriseEnabled(): EnterpriseState {
const { tier } = useTier();
return { enabled: tier === "enterprise", loading: false };
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
vi.mock("react-i18next", () => ({
@@ -7,6 +7,12 @@ vi.mock("react-i18next", () => ({
i18n: { changeLanguage: vi.fn() },
}),
}));
const enterprise = { enabled: true, loading: false };
vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({
useEnterpriseEnabled: () => enterprise,
}));
// Stub the live tab panels so the test doesn't pull their data dependencies.
vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({
ApiKeysTab: () => <div data-testid="api-keys-tab" />,
@@ -18,6 +24,11 @@ vi.mock("@portal/components/infrastructure/AuditTab", () => ({
import { Infrastructure } from "@portal/views/Infrastructure";
describe("Infrastructure (SaaS)", () => {
beforeEach(() => {
enterprise.enabled = true;
enterprise.loading = false;
});
it("defaults to the live API keys tab and drops the manage-editor button", () => {
render(<Infrastructure />);
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
@@ -41,4 +52,14 @@ describe("Infrastructure (SaaS)", () => {
}),
).toBeEnabled();
});
it("disables the audit tab for non-enterprise tenants", () => {
enterprise.enabled = false;
render(<Infrastructure />);
expect(
screen.getByRole("button", {
name: /portal.infrastructure.tabs.audit/,
}),
).toBeDisabled();
});
});
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, type TabItem } from "@app/ui";
import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled";
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
import "@portal/views/Infrastructure.css";
@@ -21,6 +22,8 @@ type InfraTab =
export function Infrastructure() {
const { t } = useTranslation();
const [tab, setTab] = useState<InfraTab>("api-keys");
// Audit is Enterprise-only; disabled (greyed, inert) for non-enterprise tenants.
const auditEnabled = useEnterpriseEnabled().enabled;
const comingSoon = (labelKey: string) => (
<>
@@ -33,7 +36,11 @@ export function Infrastructure() {
const tabs: TabItem<InfraTab>[] = [
{ key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") },
{ key: "audit", label: t("portal.infrastructure.tabs.audit") },
{
key: "audit",
label: t("portal.infrastructure.tabs.audit"),
disabled: !auditEnabled,
},
{
key: "deployments",
label: comingSoon("portal.infrastructure.tabs.deployments"),
@@ -29,6 +29,9 @@ import {
type AuditFilter = "all" | AuditCategory;
// Enterprise-only: the Infrastructure view disables this tab for non-enterprise
// instances, so this component only ever renders when entitled. The backend still
// scopes the log to admins / team leads (403 -> forbidden state below).
export function AuditTab() {
const { t } = useTranslation();
const { tier } = useTier();
@@ -0,0 +1,17 @@
// Enterprise-license flag for the portal, from the backend app-config (`runningEE`).
// Gates Enterprise-only surfaces (e.g. Infrastructure > Audit) so they show a locked
// upsell instead of firing a doomed 403 request. The SaaS build shadows this file to
// derive enterprise from the plan tier (wallet-backed) - see portal-saas.
import { useAppConfig } from "@app/contexts/AppConfigContext";
export interface EnterpriseState {
enabled: boolean;
// True while the flag is still resolving, so callers can hold rather than flash a lock.
loading: boolean;
}
export function useEnterpriseEnabled(): EnterpriseState {
const { config, loading } = useAppConfig();
return { enabled: Boolean(config?.runningEE), loading };
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
@@ -14,6 +14,11 @@ vi.mock("@portal/contexts/ViewContext", () => ({
useView: () => ({ setActiveView: vi.fn() }),
}));
const enterprise = { enabled: true, loading: false };
vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({
useEnterpriseEnabled: () => enterprise,
}));
vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({
ApiKeysTab: () => <div data-testid="api-keys-tab" />,
}));
@@ -42,6 +47,11 @@ function tabButtons() {
}
describe("Infrastructure view", () => {
beforeEach(() => {
enterprise.enabled = true;
enterprise.loading = false;
});
it("orders the working tabs first and defaults to API keys", () => {
renderView();
@@ -91,4 +101,24 @@ describe("Infrastructure view", () => {
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
});
it("disables the audit tab (inert, never opens) for non-enterprise users", () => {
enterprise.enabled = false;
renderView();
const auditBtn = screen.getByRole("button", { name: `${T}.audit` });
expect(auditBtn).toBeDisabled();
fireEvent.click(auditBtn);
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
});
it("ignores a ?tab=audit deep link when not enterprise", () => {
enterprise.enabled = false;
renderView("/infrastructure?tab=audit");
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
});
});
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, Tabs, type TabItem } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled";
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
import "@portal/views/Infrastructure.css";
@@ -12,30 +13,39 @@ type InfraTab = "api-keys" | "audit";
/** Shown but inert: no backend behind these screens yet. */
type DisabledInfraTab = "deployments" | "security" | "models" | "storage";
const ENABLED_TABS: InfraTab[] = ["api-keys", "audit"];
export function Infrastructure() {
const { t } = useTranslation();
const [tab, setTab] = useState<InfraTab>("api-keys");
const { setActiveView } = useView();
const [searchParams, setSearchParams] = useSearchParams();
// Audit is Enterprise-only; disabled (greyed, inert) on non-enterprise instances.
const auditEnabled = useEnterpriseEnabled().enabled;
const canOpenTab = useCallback(
(key: string) => key === "api-keys" || (key === "audit" && auditEnabled),
[auditEnabled],
);
// Deep-link (?tab=<key>) from elsewhere (e.g. the home visualiser's outcome
// cards → audit log): open that tab, then drop the param.
useEffect(() => {
const requested = searchParams.get("tab");
if (!requested) return;
if ((ENABLED_TABS as string[]).includes(requested)) {
if (canOpenTab(requested)) {
setTab(requested as InfraTab);
}
const next = new URLSearchParams(searchParams);
next.delete("tab");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
}, [searchParams, setSearchParams, canOpenTab]);
const tabs: TabItem<InfraTab | DisabledInfraTab>[] = [
{ key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") },
{ key: "audit", label: t("portal.infrastructure.tabs.audit") },
{
key: "audit",
label: t("portal.infrastructure.tabs.audit"),
disabled: !auditEnabled,
},
{
key: "deployments",
label: t("portal.infrastructure.tabs.deployments"),
@@ -78,7 +88,7 @@ export function Infrastructure() {
items={tabs}
activeKey={tab}
onChange={(key) => {
if ((ENABLED_TABS as string[]).includes(key)) setTab(key as InfraTab);
if (canOpenTab(key)) setTab(key as InfraTab);
}}
variant="underline"
ariaLabel={t("portal.infrastructure.sectionsAriaLabel")}